Author SHA1 Message Date
manoraga 74332aa38a feat(turnstile): двухконтурный учет СКУД, реестры исключений с автокомплитом 1С и калибровка таймзон 2026-09-10 15:36:32 +03:00
manoraga 6c9b131cf2 feat(scud): добавление таблицы scud_events_raw и сохранение детальной ленты проходов 2026-09-07 18:25:06 +03:00
manoraga 17c960fa34 fix(scud): исправление маппинга Mode (1=IN, 2=OUT) и привязка FilteredLastOut к финальному событию дня 2026-09-07 17:41:59 +03:00
manoraga d391a08224 feat(web): стабилизация UI, Gemini-скроллинг, роутеры контекста/снапшотов и актуализация роадмапа 2026-09-07 17:29:21 +03:00
manoraga 1bb95cd8e1 chore: ignore sqlite temporary wal and shm files 2026-08-28 00:12:39 +03:00
manoraga 27e8055a3d feat: implement svodka/otchet generators, Y-23:59:59 and snap-to-grid time finder 2026-08-28 00:09:57 +03:00
manoraga fa386b3b79 docs: update CHANGELOG and ROADMAP with section 10 (svodka/otchet split, Y-23:59:59) 2026-08-27 22:43:45 +03:00
manoraga a9680db0aa feat(etl): stable pipeline, exception registry in SQLite, multi-pass aggregation and db_cli 2026-08-27 19:26:28 +03:00
manoraga 66087d5806 merge: внедрение нативного Function Calling, двухфазного жизненного цикла и Topic Drift Guard 2026-08-18 13:49:48 +03:00
manoraga 4b1b8a3586 feat(llm): двухфазный жизненный цикл сессий, двусторонний Diff, Topic Drift Guard и актуализация документации 2026-08-18 13:47:26 +03:00
manoraga 828ce67817 feat(llm): переход на реляционные узлы промпта, db_cli context и очистка от регулярок 2026-08-17 11:55:07 +03:00
manoraga 4e3c99b728 refactor(web_api): fix import paths, modularize routers and decompose agent pipeline 2026-08-15 15:41:37 +03:00
101 changed files with 20326 additions and 9574 deletions
+5
View File
@@ -27,3 +27,8 @@ data/1c/*
!data/scud/.gitkeep !data/scud/.gitkeep
!data/1c/.gitkeep !data/1c/.gitkeep
!data/static_reason_workers.csv !data/static_reason_workers.csv
*.db-shm
*.db-wal
data/scud_orion_ai.db
data/uploads/
+28 -18
View File
@@ -8,11 +8,11 @@ DATA_DIR = os.path.join(BASE_DIR, "data")
SCUD_DIR = os.path.join(DATA_DIR, "scud") SCUD_DIR = os.path.join(DATA_DIR, "scud")
ZUP_1C_DIR = os.path.join(DATA_DIR, "1c") ZUP_1C_DIR = os.path.join(DATA_DIR, "1c")
OUTPUT_DIR = os.path.join(BASE_DIR, "output") OUTPUT_DIR = os.path.join(BASE_DIR, "output")
REPORTS_DIR = os.path.join(OUTPUT_DIR, "reports")
# Путь к намонтированной сетевой шаре 1С в Linux (вместо \\storage\SCUD\Обмен\Штат)
SHARE_1C_DIR = "/mnt/scud_share" SHARE_1C_DIR = "/mnt/scud_share"
for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR]: for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR, REPORTS_DIR]:
os.makedirs(folder, exist_ok=True) os.makedirs(folder, exist_ok=True)
NOW = datetime.now() NOW = datetime.now()
@@ -23,32 +23,30 @@ if NOW.weekday() == 0:
else: else:
DATE_YESTERDAY = (NOW - timedelta(days=1)).strftime("%d.%m.%Y") DATE_YESTERDAY = (NOW - timedelta(days=1)).strftime("%d.%m.%Y")
# --- Настройки LLM / Ollama --- OLLAMA_URL = "http://10.121.17.227:11434/api/generate"
OLLAMA_URL = "http://192.168.11.3:11434/api/generate"
OLLAMA_MODEL = "qwen2.5:14b" OLLAMA_MODEL = "qwen2.5:14b"
MODEL_NAME = OLLAMA_MODEL MODEL_NAME = OLLAMA_MODEL
KNOWLEDGE_BASE_PATH = os.path.join(DATA_DIR, "knowledge_base.json") KNOWLEDGE_BASE_PATH = os.path.join(DATA_DIR, "knowledge_base.json")
EXCEPTIONS_PATH = os.path.join(BASE_DIR, "exceptions.json") EXCEPTIONS_PATH = os.path.join(BASE_DIR, "exceptions.json")
# --- Настройки подключения к MS SQL Server (1С:ЗУП 3.1) ---
ZUP_SQL_CONFIG = { ZUP_SQL_CONFIG = {
"driver": "{ODBC Driver 18 for SQL Server}", # или "{ODBC Driver 17 for SQL Server}" "driver": "{ODBC Driver 18 for SQL Server}",
"server": os.getenv("ZUP_SQL_SERVER", "ACCOUNT-01"), "server": os.getenv("ZUP_SQL_SERVER", "ACCOUNT-01"),
"database": os.getenv("ZUP_SQL_DB", "ZUP30"), # Замените на точное имя базы ЗУП на ACCOUNT-01 "database": os.getenv("ZUP_SQL_DB", "ZUP30"),
"user": os.getenv("ZUP_SQL_USER", "scud_reader"), # Логин SQL Server "user": os.getenv("ZUP_SQL_USER", "scud_reader"),
"password": os.getenv("ZUP_SQL_PASS", "Rhfcysq90"), # Пароль SQL Server "password": os.getenv("ZUP_SQL_PASS", "Rhfcysq90"),
"trust_server_certificate": "yes", # Доверять сертификату сервера "trust_server_certificate": "yes",
"encrypt": "no" # "yes", если на MS SQL включено обязательное SSL-шифрование "encrypt": "no"
} }
def find_dated_file(prefix, date_str, search_dirs=[ZUP_1C_DIR, SCUD_DIR, DATA_DIR, "."]): def find_dated_file(prefix, date_str, search_dirs=None):
""" if search_dirs is None:
Ищет файлы по префиксу и дате (поддерживает и 30.07.2026, и 30_07_2026). search_dirs = [ZUP_1C_DIR, SCUD_DIR, DATA_DIR, "."]
"""
date_dots = date_str date_dots = str(date_str).replace('_', '.')
date_underscores = date_str.replace('.', '_') date_underscores = date_dots.replace('.', '_')
for d in search_dirs: for d in search_dirs:
if not os.path.exists(d): if not os.path.exists(d):
@@ -75,6 +73,18 @@ def clean_scud_fio_light(fio_str):
def load_exceptions(): def load_exceptions():
"""
Приоритетно читает исключения и белый список из SQLite таблицы exceptions_registry.
При отсутствии таблицы или пустой базе выполняет fallback на exceptions.json.
"""
try:
from services.exceptions_repo import get_all_exceptions_from_db
db_exc = get_all_exceptions_from_db()
if any(db_exc.values()):
return db_exc
except Exception:
pass
import json import json
if os.path.exists(EXCEPTIONS_PATH): if os.path.exists(EXCEPTIONS_PATH):
try: try:
@@ -82,4 +92,4 @@ def load_exceptions():
return json.load(f) return json.load(f)
except Exception: except Exception:
pass pass
return {"fio": [], "departments": [], "positions": [], "position_keywords": []} return {"fio": [], "departments": [], "positions": [], "position_keywords": [], "include_fio": []}
+27
View File
@@ -0,0 +1,27 @@
"""
===============================================================================
FILE: core/connection.py
PROJECT: SCUD Orion AI (Unified Architecture)
ROLE: Единый менеджер подключений к базе данных SQLite (WAL mode, timeouts).
===============================================================================
"""
import os
import sqlite3
from config import DATA_DIR
DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
def get_connection(row_factory: bool = False) -> sqlite3.Connection:
"""
Создает оптимизированное подключение к SQLite.
row_factory=True возвращает sqlite3.Row для доступа к полям по имени.
"""
conn = sqlite3.connect(DB_PATH, timeout=30.0)
if row_factory:
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON;")
conn.execute("PRAGMA journal_mode = WAL;")
conn.execute("PRAGMA synchronous = NORMAL;")
return conn
+35 -480
View File
@@ -1,480 +1,35 @@
import os """
import sqlite3 ===============================================================================
import json FILE: core/database.py
import pandas as pd ROLE: Фасад ядра базы данных с полной обратной совместимостью импортов.
from datetime import datetime ===============================================================================
from config import DATA_DIR """
DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db") from core.connection import get_connection, DB_PATH
from core.schema import init_all_tables
def get_connection(): from core.repositories.scud_repo import (
"""Создает подключение к базе данных SQLite с оптимизированными настройками.""" has_scud_logs_for_date,
conn = sqlite3.connect(DB_PATH) has_yesterday_final_snapshot,
conn.execute("PRAGMA foreign_keys = ON;") get_or_create_snapshot_id,
conn.execute("PRAGMA journal_mode = WAL;") save_scud_to_db,
return conn get_latest_snapshot_time,
load_scud_from_db_by_snapshot,
get_available_snapshots,
def init_db(): delete_snapshot_by_id,
"""Создает структуру таблиц и индексов в базе данных.""" delete_snapshots_by_date
with get_connection() as conn: )
cursor = conn.cursor()
from core.repositories.zup_repo import (
cursor.execute(""" save_staff_to_db,
CREATE TABLE IF NOT EXISTS scud_logs ( load_staff_from_db,
id INTEGER PRIMARY KEY AUTOINCREMENT, save_absences_to_db,
log_date TEXT NOT NULL, load_absences_from_db,
fio TEXT NOT NULL, save_anomalies_to_db,
fio_clean TEXT NOT NULL, get_all_rules_from_db,
department TEXT, add_rule_to_db,
position TEXT, get_department_synonyms_dict,
time_in TEXT, add_department_synonym_to_db
first_activity TEXT DEFAULT '—', )
time_out TEXT,
time_in_building TEXT, init_db = init_all_tables
is_present INTEGER NOT NULL,
anomaly_flag TEXT DEFAULT 'NONE',
snapshot_time TEXT DEFAULT NULL,
snapshot_id TEXT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("PRAGMA table_info(scud_logs);")
cols = [col[1] for col in cursor.fetchall()]
if 'snapshot_id' not in cols:
cursor.execute("ALTER TABLE scud_logs ADD COLUMN snapshot_id TEXT DEFAULT NULL;")
cursor.execute("""
CREATE TABLE IF NOT EXISTS zup_staff (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date TEXT NOT NULL,
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
department TEXT,
position TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS zup_absences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
absence_date TEXT NOT NULL,
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
absence_type TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS anomalies_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anomaly_date TEXT NOT NULL,
fio TEXT NOT NULL,
anomaly_type TEXT NOT NULL,
details TEXT NOT NULL,
human_status TEXT DEFAULT 'Pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS ai_knowledge_base (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_text TEXT UNIQUE NOT NULL,
added_by TEXT DEFAULT 'Human',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_fio ON scud_logs(fio_clean);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_snapshot ON scud_logs(snapshot_time);")
conn.commit()
sync_knowledge_base_to_db()
def has_scud_logs_for_date(date_str):
"""Проверяет, есть ли в базе данные СКУД за указанную дату."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT 1 FROM scud_logs WHERE log_date = ? LIMIT 1", (date_str,))
return cursor.fetchone() is not None
def has_yesterday_final_snapshot(date_str):
"""Проверяет, зафиксирован ли уже ИТОГОВЫЙ вчерашний снапшот с индексом Y/22:00."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT 1 FROM scud_logs WHERE log_date = ? AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%22:00:00') LIMIT 1",
(date_str,)
)
return cursor.fetchone() is not None
def get_or_create_snapshot_id(snapshot_time, date_str=None, is_yesterday=False):
"""
Генерирует датированный составной ID снапшота (YYYYMMDD-NNN или YYYYMMDD-NNN).
Префикс 'Y' присваивается автоматически, если дата логов (date_str) предшествует дате снятия (snapshot_time).
"""
try:
dt_snap = datetime.strptime(snapshot_time, "%Y-%m-%d %H:%M:%S").date()
date_prefix = dt_snap.strftime("%Y%m%d")
except (ValueError, TypeError):
dt_snap = datetime.now().date()
date_prefix = dt_snap.strftime("%Y%m%d")
# Автоматическая проверка: если дата логов раньше даты выгрузки — проставляем Y
if date_str:
try:
dt_log = datetime.strptime(date_str, "%d.%m.%Y").date()
if dt_log < dt_snap:
is_yesterday = True
except Exception:
pass
prefix = "Y" if is_yesterday else ""
with get_connection() as conn:
cursor = conn.cursor()
if date_str:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
(date_str, snapshot_time)
)
else:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
(snapshot_time,)
)
row = cursor.fetchone()
if row and row[0]:
return row[0]
cursor.execute("""
SELECT snapshot_id
FROM scud_logs
WHERE snapshot_id LIKE ? OR snapshot_id LIKE ?
ORDER BY snapshot_id DESC LIMIT 1
""", (f"{date_prefix}-%", f"Y{date_prefix}-%"))
last_row = cursor.fetchone()
next_seq = 1
if last_row and last_row[0]:
parts = last_row[0].replace("Y", "").split("-")
if len(parts) > 1 and parts[1].isdigit():
next_seq = int(parts[1]) + 1
return f"{prefix}{date_prefix}-{next_seq:03d}"
def save_scud_to_db(df_scud, date_str, snapshot_time=None, is_yesterday=False):
"""Сохраняет логи СКУД с автоматическим присвоением составного snapshot_id."""
if df_scud is None or df_scud.empty:
return
if not snapshot_time:
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Передаем date_str в генератор snapshot_id
snapshot_id = get_or_create_snapshot_id(snapshot_time, date_str=date_str, is_yesterday=is_yesterday)
data_to_insert = []
for _, r in df_scud.iterrows():
data_to_insert.append((
date_str,
r.get('Сотрудник', r.get('fio_raw', '')),
r.get('fio_clean', ''),
r.get('Подразделение', ''),
r.get('Должность', ''),
r.get('Начало_дня', 'Нет входа'),
r.get('Первая_активность', '—'),
r.get('Конец_дня', 'Нет выхода'),
r.get('Находился_в_здании', '00:00'),
1 if r.get('Пришел', False) else 0,
r.get('anomaly_flag', 'NONE'),
snapshot_time,
snapshot_id
))
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? AND snapshot_time = ?", (date_str, snapshot_time))
cursor.executemany("""
INSERT INTO scud_logs (
log_date, fio, fio_clean, department, position,
time_in, first_activity, time_out, time_in_building,
is_present, anomaly_flag, snapshot_time, snapshot_id
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", data_to_insert)
conn.commit()
def get_latest_snapshot_time(date_str=None):
with get_connection() as conn:
cursor = conn.cursor()
if date_str:
cursor.execute("SELECT snapshot_time FROM scud_logs WHERE log_date = ? AND snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1", (date_str,))
else:
cursor.execute("SELECT snapshot_time FROM scud_logs WHERE snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1")
row = cursor.fetchone()
return row[0] if row else None
def load_scud_from_db_by_snapshot(date_str, snapshot_param=None):
"""
Загружает логи СКУД по ID снапшота.
Если snapshot_param не задан (или искался прошлый день), приоритетно выбирает
финальный вечерний снапшот, начинающийся с буквы 'Y' (срез на 22:00) за дату date_str.
"""
with get_connection() as conn:
df = pd.DataFrame()
# 1. Если передан конкретный ID снапшота (например, '20260810-001')
if snapshot_param:
df = pd.read_sql_query(
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
conn, params=(str(snapshot_param),)
)
# 2. Если по snapshot_param ничего не найдено ИЛИ snapshot_param=None (запрос за вчерашний день):
if df.empty and date_str:
cursor = conn.cursor()
# ⭐️ ПРИОРИТЕТ 1: Ищем срез, начальный символ которого 'Y' (вечерний зафиксированный Y-снапшот)
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY id DESC LIMIT 1",
(date_str,)
)
row = cursor.fetchone()
# ⭐️ ПРИОРИТЕТ 2: Если Y-снапшот не найден, берем самый свежий обычный
if not row:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY id DESC LIMIT 1",
(date_str,)
)
row = cursor.fetchone()
if row and row[0]:
target_snap_id = row[0]
df = pd.read_sql_query(
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
conn, params=(target_snap_id,)
)
# Приводим названия колонок к стандарту
if not df.empty:
rename_map = {
'department': 'Подразделение',
'position': 'Должность',
'fio': 'Сотрудник',
'time_in': 'Начало_дня',
'first_activity': 'Первая_активность',
'time_out': 'Конец_дня',
'duration': 'Находился_в_здании',
'is_present': 'Пришел'
}
# Переименовываем имеющиеся колонки
df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
# Заполняем недостающие колонки значениями по умолчанию, если их не было в БД
required_cols = ['Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']
for col in required_cols:
if col not in df.columns:
if col == 'Пришел':
df[col] = False
else:
df[col] = '—'
# Приводим тип флага Пришел к bool
if 'Пришел' in df.columns:
df['Пришел'] = df['Пришел'].astype(bool)
return df
def get_available_snapshots(date_str=None):
"""
Возвращает список снапшотов.
Сортировка по времени snapshot_time DESC.
"""
with get_connection() as conn:
cursor = conn.cursor()
if date_str:
cursor.execute("""
SELECT
snapshot_id,
log_date,
snapshot_time,
COUNT(*) as cnt
FROM scud_logs
WHERE log_date = ? AND snapshot_time IS NOT NULL
GROUP BY snapshot_id, log_date, snapshot_time
ORDER BY snapshot_time DESC
""", (date_str,))
else:
cursor.execute("""
SELECT
snapshot_id,
log_date,
snapshot_time,
COUNT(*) as cnt
FROM scud_logs
WHERE snapshot_time IS NOT NULL
GROUP BY snapshot_id, log_date, snapshot_time
ORDER BY snapshot_time DESC
""")
return cursor.fetchall()
def save_staff_to_db(df_staff, date_str):
if df_staff is None or df_staff.empty:
return
data_to_insert = [
(date_str, r.get('ФИО', ''), r.get('fio_clean', ''), r.get('Подразделение', ''), r.get('Должность', ''))
for _, r in df_staff.iterrows()
]
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM zup_staff WHERE snapshot_date = ?", (date_str,))
cursor.executemany("INSERT INTO zup_staff (snapshot_date, fio, fio_clean, department, position) VALUES (?, ?, ?, ?, ?)", data_to_insert)
conn.commit()
def save_absences_to_db(df_absent, date_str):
if df_absent is None or df_absent.empty:
return
data_to_insert = [
(date_str, r.get('ФИО', r.get('fio_clean', '')), r.get('fio_clean', ''), r.get('Вид_отсутствия', ''))
for _, r in df_absent.iterrows()
]
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM zup_absences WHERE absence_date = ?", (date_str,))
cursor.executemany("INSERT INTO zup_absences (absence_date, fio, fio_clean, absence_type) VALUES (?, ?, ?, ?)", data_to_insert)
conn.commit()
def save_anomalies_to_db(anomalies_list, date_str):
if not anomalies_list:
return
data_to_insert = [
(date_str, a.get('fio', ''), a.get('type', ''), a.get('details', ''))
for a in anomalies_list
]
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM anomalies_history WHERE anomaly_date = ?", (date_str,))
cursor.executemany("INSERT INTO anomalies_history (anomaly_date, fio, anomaly_type, details) VALUES (?, ?, ?, ?)", data_to_insert)
conn.commit()
def get_all_rules_from_db():
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT rule_text FROM ai_knowledge_base")
return [r[0] for r in cursor.fetchall()]
def add_rule_to_db(rule_text, added_by="Human"):
if not rule_text or not rule_text.strip():
return
with get_connection() as conn:
cursor = conn.cursor()
try:
cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)", (rule_text.strip(), added_by))
conn.commit()
except Exception as e:
print(f"[⚠️] Ошибка записи правила в БД: {e}")
def sync_knowledge_base_to_db():
pass
def delete_snapshot_by_id(snapshot_id: str):
"""Удаляет конкретный снапшот из таблицы scud_logs по его ID."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
deleted_count = cursor.rowcount
conn.commit()
print(f"[✓] Удален снапшот [{snapshot_id}]. Удалено строк: {deleted_count}")
return deleted_count
def delete_snapshots_by_date(date_str: str):
"""Удаляет все снапшоты за указанную дату (например, '04.08.2026')."""
with get_connection() as conn:
cursor = conn.cursor()
# Удаляем по log_date или по дате внутри snapshot_id
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%"))
deleted_count = cursor.rowcount
conn.commit()
print(f"[✓] Удалены все снапшоты за дату [{date_str}]. Удалено строк: {deleted_count}")
return deleted_count
def init_department_synonyms_db():
"""Создает таблицу синонимов отделов в БД SQLite."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS department_synonyms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
short_name TEXT UNIQUE NOT NULL,
full_name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# По умолчанию добавляем ОВК -> Отдел внутреннего контроля
cursor.execute("""
INSERT OR IGNORE INTO department_synonyms (short_name, full_name)
VALUES ('овк', 'отдел внутреннего контроля')
""")
conn.commit()
def get_department_synonyms_dict():
"""Возвращает словарь всех изученных синонимов {short_name: full_name}."""
init_department_synonyms_db()
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT LOWER(short_name), LOWER(full_name) FROM department_synonyms")
return {row[0]: row[1] for row in cursor.fetchall()}
def add_department_synonym_to_db(short_name, full_name):
"""Сохраняет новую пару синонимов отдела в базу SQLite."""
init_department_synonyms_db()
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT OR REPLACE INTO department_synonyms (short_name, full_name) VALUES (?, ?)",
(short_name.strip().lower(), full_name.strip().lower())
)
conn.commit()
print(f"[✓] В базу SQLite добавлен новый синоним отдела: '{short_name}' ⟷ '{full_name}'")
def load_staff_from_db(date_str):
"""Загружает Штатное расписание 1С из базы SQLite за указанную дату."""
with get_connection() as conn:
df = pd.read_sql_query(
"SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?",
conn, params=(date_str,)
)
return df if not df.empty else None
def load_absences_from_db(date_str):
"""Загружает документальные отсутствия 1С из базы SQLite за указанную дату."""
with get_connection() as conn:
df = pd.read_sql_query(
"SELECT fio as 'ФИО', fio_clean, absence_type as 'Вид_отсутствия' FROM zup_absences WHERE absence_date = ?",
conn, params=(date_str,)
)
return df if not df.empty else None
+359
View File
@@ -0,0 +1,359 @@
"""
===============================================================================
FILE: core/repositories/scud_repo.py
ROLE: Репозиторий логов СКУД, сохранение и загрузка снапшотов.
===============================================================================
"""
from datetime import datetime
import pandas as pd
from core.connection import get_connection
def has_scud_logs_for_date(date_str: str) -> bool:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT 1 FROM scud_logs WHERE log_date = ? LIMIT 1", (date_str,))
return cursor.fetchone() is not None
def has_yesterday_final_snapshot(date_str: str) -> bool:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT 1 FROM scud_logs
WHERE log_date = ?
AND (snapshot_id LIKE 'Y%' OR snapshot_id LIKE '%_FINAL%' OR snapshot_time LIKE '%23:59:59' OR snapshot_time LIKE '%22:00:00')
LIMIT 1
""",
(date_str,)
)
return cursor.fetchone() is not None
def get_or_create_snapshot_id(snapshot_time: str, date_str: str = None, is_yesterday: bool = False) -> str:
"""
Генерирует понятный и уникальный ID снапшота:
- Дата префикса берется строго из даты самих логов (date_str).
- Для итоговых срезов дня: YYYYYMMDD_FINAL (строго с буквой Y в начале).
- Для дневных срезов на время: YYYYMMDD_HHMM.
"""
if date_str:
try:
dt_log = datetime.strptime(date_str.replace('_', '.'), "%d.%m.%Y").date()
date_prefix = dt_log.strftime("%Y%m%d")
except Exception:
dt_log = datetime.now().date()
date_prefix = dt_log.strftime("%Y%m%d")
else:
try:
dt_snap = datetime.strptime(snapshot_time.split()[0], "%Y-%m-%d").date()
date_prefix = dt_snap.strftime("%Y%m%d")
except (ValueError, TypeError, IndexError):
date_prefix = datetime.now().strftime("%Y%m%d")
time_part = "2359"
try:
t_str = snapshot_time.split()[1] if " " in snapshot_time else snapshot_time
t_parts = t_str.split(":")
time_part = f"{t_parts[0]}{t_parts[1]}"
except Exception:
pass
with get_connection() as conn:
cursor = conn.cursor()
# Если срез с точно таким же временем и датой уже существует — возвращаем его ID
if date_str:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
(date_str, snapshot_time)
)
else:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
(snapshot_time,)
)
row = cursor.fetchone()
if row and row[0]:
return row[0]
# Финальный суточный ID: строго с префиксом Y
if is_yesterday or time_part in ["2359", "2200"]:
base_final_id = f"Y{date_prefix}_FINAL"
cursor.execute("SELECT 1 FROM scud_logs WHERE snapshot_id = ? LIMIT 1", (base_final_id,))
if not cursor.fetchone():
return base_final_id
return base_final_id
# Дневной срез на определенное время
base_id = f"{date_prefix}_{time_part}"
cursor.execute("SELECT 1 FROM scud_logs WHERE snapshot_id = ? LIMIT 1", (base_id,))
if not cursor.fetchone():
return base_id
cursor.execute("""
SELECT DISTINCT snapshot_id
FROM scud_logs
WHERE snapshot_id LIKE ?
""", (f"{base_id}-%",))
rows = cursor.fetchall()
max_seq = 1
for (s_id,) in rows:
if not s_id:
continue
try:
parts = str(s_id).split('-')
if len(parts) >= 2 and parts[-1].isdigit():
num = int(parts[-1])
if num > max_seq:
max_seq = num
except Exception:
continue
next_seq = max_seq + 1
return f"{base_id}-{next_seq:03d}"
def save_scud_to_db(df_scud: pd.DataFrame, date_str: str, snapshot_time: str = None, is_yesterday: bool = False) -> None:
if df_scud is None or df_scud.empty:
return
now_local_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if not snapshot_time:
snapshot_time = now_local_str
snapshot_id = get_or_create_snapshot_id(snapshot_time, date_str=date_str, is_yesterday=is_yesterday)
data_to_insert = [
(
date_str,
r.get('Сотрудник', r.get('fio_raw', '')),
r.get('fio_clean', ''),
r.get('Подразделение', ''),
r.get('Должность', ''),
r.get('Начало_дня', 'Нет входа'),
r.get('Первая_активность', '—'),
r.get('Конец_дня', 'Нет выхода'),
r.get('Находился_в_здании', '00:00'),
1 if r.get('Пришел', False) else 0,
r.get('anomaly_flag', 'NONE'),
snapshot_time,
snapshot_id,
now_local_str # ⭐️ Передаем локальное время машины напрямую
)
for _, r in df_scud.iterrows()
]
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? AND snapshot_time = ?", (date_str, snapshot_time))
cursor.executemany("""
INSERT INTO scud_logs (
log_date, fio, fio_clean, department, position,
time_in, first_activity, time_out, time_in_building,
is_present, anomaly_flag, snapshot_time, snapshot_id, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", data_to_insert)
conn.commit()
def get_latest_snapshot_time(date_str: str = None):
with get_connection() as conn:
cursor = conn.cursor()
if date_str:
cursor.execute("SELECT snapshot_time FROM scud_logs WHERE log_date = ? AND snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1", (date_str,))
else:
cursor.execute("SELECT snapshot_time FROM scud_logs WHERE snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1")
row = cursor.fetchone()
return row[0] if row else None
def load_scud_from_db_by_snapshot(date_str: str, snapshot_param: str = None) -> pd.DataFrame:
with get_connection() as conn:
df = pd.DataFrame()
if snapshot_param:
df = pd.read_sql_query(
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
conn, params=(str(snapshot_param),)
)
if df.empty and date_str:
cursor = conn.cursor()
cursor.execute(
"""
SELECT snapshot_id FROM scud_logs
WHERE log_date = ?
AND (snapshot_id LIKE 'Y%' OR snapshot_id LIKE '%_FINAL%')
ORDER BY snapshot_time DESC, id DESC LIMIT 1
""",
(date_str,)
)
row = cursor.fetchone()
if not row:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY snapshot_time DESC, id DESC LIMIT 1",
(date_str,)
)
row = cursor.fetchone()
if row and row[0]:
target_id = row[0]
df = pd.read_sql_query(
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
conn, params=(target_id,)
)
if not df.empty:
rename_map = {
'department': 'department_scud',
'position': 'Должность',
'fio': 'Сотрудник',
'time_in': 'Начало_дня',
'first_activity': 'Первая_активность',
'time_out': 'Конец_дня',
'time_in_building': 'Находился_в_здании',
'is_present': 'Пришел'
}
df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
if 'department_scud' in df.columns and 'Подразделение' not in df.columns:
df['Подразделение'] = df['department_scud']
for col in ['Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']:
if col not in df.columns:
df[col] = False if col == 'Пришел' else '—'
if 'Пришел' in df.columns:
df['Пришел'] = df['Пришел'].astype(bool)
return df
def get_available_snapshots(date_str: str = None):
with get_connection() as conn:
cursor = conn.cursor()
query = """
SELECT
snapshot_id,
log_date,
snapshot_time,
COUNT(*) as cnt,
MIN(created_at) as created_at
FROM scud_logs
WHERE snapshot_time IS NOT NULL
"""
params = []
if date_str:
query += " AND log_date = ?"
params.append(date_str)
query += " GROUP BY snapshot_id, log_date, snapshot_time ORDER BY snapshot_time DESC"
cursor.execute(query, params)
return cursor.fetchall()
def delete_snapshot_by_id(snapshot_id: str) -> int:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
cnt = cursor.rowcount
conn.commit()
return cnt
def delete_snapshots_by_date(date_str: str) -> int:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%"))
cnt = cursor.rowcount
conn.commit()
return cnt
def save_raw_events_to_db(df_raw_events: pd.DataFrame, date_str: str) -> int:
if df_raw_events is None or df_raw_events.empty:
return 0
records = [
(
date_str,
str(r.get('TimeVal', '')),
int(r.get('HozOrgan', 0)),
str(r.get('Сотрудник', '')),
str(r.get('fio_clean', '')),
str(r.get('Подразделение', '')),
int(r.get('Event', 0)),
int(r.get('Mode', 0)),
int(r.get('DoorIndex')) if pd.notna(r.get('DoorIndex')) else None,
str(r.get('Direction', 'OTHER'))
)
for _, r in df_raw_events.iterrows()
]
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_events_raw WHERE log_date = ?", (date_str,))
cursor.executemany("""
INSERT INTO scud_events_raw (
log_date, time_val, hoz_organ, fio, fio_clean,
department, event_code, mode, door_index, direction
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", records)
conn.commit()
return len(records)
def get_building_presence(date_str: str, only_inside: bool = True) -> list[dict]:
with get_connection() as conn:
cursor = conn.cursor()
query = """
WITH RankedEvents AS (
SELECT
hoz_organ,
fio,
department,
time_val,
direction,
ROW_NUMBER() OVER (
PARTITION BY hoz_organ
ORDER BY time_val DESC, id DESC
) as rn
FROM scud_events_raw
WHERE log_date = ?
)
SELECT
hoz_organ,
fio,
department,
time_val,
direction
FROM RankedEvents
WHERE rn = 1
"""
if only_inside:
query += " AND direction = 'IN'"
query += " ORDER BY fio ASC;"
cursor.execute(query, (date_str,))
rows = cursor.fetchall()
results = [
{
"hoz_organ": r[0],
"fio": r[1],
"department": r[2],
"last_event_time": r[3],
"direction": r[4],
"status": "В здании" if r[4] == "IN" else "Вышел"
}
for r in rows
]
results.sort(key=lambda x: x["fio"].lower())
return results
+97
View File
@@ -0,0 +1,97 @@
"""
===============================================================================
FILE: core/repositories/zup_repo.py
ROLE: Репозиторий кадровых данных 1С:ЗУП, аномалий и базы знаний.
===============================================================================
"""
import pandas as pd
from typing import List, Dict, Any
from core.connection import get_connection
def save_staff_to_db(df_staff: pd.DataFrame, date_str: str) -> None:
if df_staff is None or df_staff.empty:
return
data = [
(date_str, r.get('ФИО', ''), r.get('fio_clean', ''), r.get('Подразделение', ''), r.get('Должность', ''))
for _, r in df_staff.iterrows()
]
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM zup_staff WHERE snapshot_date = ?", (date_str,))
cursor.executemany("INSERT INTO zup_staff (snapshot_date, fio, fio_clean, department, position) VALUES (?, ?, ?, ?, ?)", data)
conn.commit()
def load_staff_from_db(date_str: str) -> pd.DataFrame:
with get_connection() as conn:
df = pd.read_sql_query(
"SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?",
conn, params=(date_str,)
)
return df if not df.empty else None
def save_absences_to_db(df_absent: pd.DataFrame, date_str: str) -> None:
if df_absent is None or df_absent.empty:
return
data = [
(date_str, r.get('ФИО', r.get('fio_clean', '')), r.get('fio_clean', ''), r.get('Вид_отсутствия', ''))
for _, r in df_absent.iterrows()
]
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM zup_absences WHERE absence_date = ?", (date_str,))
cursor.executemany("INSERT INTO zup_absences (absence_date, fio, fio_clean, absence_type) VALUES (?, ?, ?, ?)", data)
conn.commit()
def load_absences_from_db(date_str: str) -> pd.DataFrame:
with get_connection() as conn:
df = pd.read_sql_query(
"SELECT fio as 'ФИО', fio_clean, absence_type as 'Вид_отсутствия' FROM zup_absences WHERE absence_date = ?",
conn, params=(date_str,)
)
return df if not df.empty else None
def save_anomalies_to_db(anomalies_list: List[Dict[str, Any]], date_str: str) -> None:
if not anomalies_list:
return
data = [(date_str, a.get('fio', ''), a.get('type', ''), a.get('details', '')) for a in anomalies_list]
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM anomalies_history WHERE anomaly_date = ?", (date_str,))
cursor.executemany("INSERT INTO anomalies_history (anomaly_date, fio, anomaly_type, details) VALUES (?, ?, ?, ?)", data)
conn.commit()
def get_all_rules_from_db() -> List[str]:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT rule_text FROM ai_knowledge_base")
return [r[0] for r in cursor.fetchall()]
def add_rule_to_db(rule_text: str, added_by: str = "Human") -> None:
if not rule_text or not rule_text.strip():
return
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)", (rule_text.strip(), added_by))
conn.commit()
def get_department_synonyms_dict() -> Dict[str, str]:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT LOWER(short_name), LOWER(full_name) FROM department_synonyms")
return {row[0]: row[1] for row in cursor.fetchall()}
def add_department_synonym_to_db(short_name: str, full_name: str) -> None:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("INSERT OR REPLACE INTO department_synonyms (short_name, full_name) VALUES (?, ?)", (short_name.strip().lower(), full_name.strip().lower()))
conn.commit()
+196
View File
@@ -0,0 +1,196 @@
"""
===============================================================================
FILE: core/schema.py
PROJECT: SCUD Orion AI (Unified Architecture)
ROLE: DDL-схемы таблиц, создание индексов и инициализация базы данных.
===============================================================================
"""
import logging
from core.connection import get_connection
logger = logging.getLogger("DB_SCHEMA")
def init_all_tables() -> None:
"""Инициализирует все таблицы и индексы системы."""
with get_connection() as conn:
cursor = conn.cursor()
# 1. Логи СКУД
cursor.execute("""
CREATE TABLE IF NOT EXISTS scud_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
log_date TEXT NOT NULL,
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
department TEXT,
position TEXT,
time_in TEXT,
first_activity TEXT DEFAULT '—',
time_out TEXT,
time_in_building TEXT,
is_present INTEGER NOT NULL,
anomaly_flag TEXT DEFAULT 'NONE',
snapshot_time TEXT DEFAULT NULL,
snapshot_id TEXT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 1.1 Сырые физические события турникетов СКУД (для перекуров и оперативного статуса)
cursor.execute("""
CREATE TABLE IF NOT EXISTS scud_events_raw (
id INTEGER PRIMARY KEY AUTOINCREMENT,
log_date TEXT NOT NULL,
time_val TEXT NOT NULL,
hoz_organ INTEGER NOT NULL,
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
department TEXT,
event_code INTEGER NOT NULL,
mode INTEGER NOT NULL,
door_index INTEGER DEFAULT 1,
direction TEXT NOT NULL, -- 'IN' или 'OUT'
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 2. Кадровые реестры 1С
cursor.execute("""
CREATE TABLE IF NOT EXISTS zup_staff (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date TEXT NOT NULL,
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
department TEXT,
position TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS zup_absences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
absence_date TEXT NOT NULL,
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
absence_type TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 3. Аномалии, база знаний и синонимы
cursor.execute("""
CREATE TABLE IF NOT EXISTS anomalies_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anomaly_date TEXT NOT NULL,
fio TEXT NOT NULL,
anomaly_type TEXT NOT NULL,
details TEXT NOT NULL,
human_status TEXT DEFAULT 'Pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS ai_knowledge_base (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_text TEXT UNIQUE NOT NULL,
added_by TEXT DEFAULT 'Human',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS department_synonyms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
short_name TEXT UNIQUE NOT NULL,
full_name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 4. Исключения и Кэш сопоставлений личностей (ИИ / Ручной)
cursor.execute("""
CREATE TABLE IF NOT EXISTS exceptions_registry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
value TEXT NOT NULL,
comment TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(category, value)
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS person_identity_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scud_fio TEXT NOT NULL,
zup_fio TEXT NOT NULL,
scud_dept TEXT,
zup_dept TEXT,
match_source TEXT DEFAULT 'AI', -- 'AI', 'MANUAL', 'EXACT'
status TEXT DEFAULT 'ACTIVE', -- 'ACTIVE', 'PROPOSED', 'REJECTED'
confidence REAL DEFAULT 1.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(scud_fio, zup_fio)
);
""")
# 5. Узлы системного промпта, сессии, сообщения и задачи
cursor.execute("""
CREATE TABLE IF NOT EXISTS system_prompt_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
prompt_name TEXT DEFAULT 'main_agent',
section_id INTEGER NOT NULL,
item_id INTEGER NOT NULL,
content TEXT NOT NULL,
is_active INTEGER DEFAULT 1,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(prompt_name, section_id, item_id)
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS session_states (
session_id TEXT PRIMARY KEY,
state_type TEXT NOT NULL,
pending_data TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
is_ephemeral INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT,
module TEXT DEFAULT 'general',
title TEXT NOT NULL,
priority TEXT DEFAULT 'MEDIUM',
status TEXT DEFAULT 'BACKLOG',
due_date TEXT,
user_id INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 6. Индексы
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_fio ON scud_logs(fio_clean);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_mapping_scud ON person_identity_mapping(scud_fio);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_raw_events_date_hoz ON scud_events_raw(log_date, hoz_organ);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_raw_events_date_time ON scud_events_raw(log_date, time_val);")
conn.commit()
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
reason
отгул
дежурство
обучение
экзамен в Ростехнадзоре
выходной день по ТД
1 reason
2 отгул
3 дежурство
4 обучение
5 экзамен в Ростехнадзоре
6 выходной день по ТД
+13 -14
View File
@@ -1,14 +1,13 @@
fio,reason,note fio,department,reason,date_from,date_to
Королёва Наталья Александровна,Удаленная работа,Постоянная удаленка Королёва Наталья Александровна,Все,Удаленная работа,,
Николаева Ирина Леонидовна,Удаленная работа,Постоянная удаленка Николаева Ирина Леонидовна,Все,Удаленная работа,,
Познякова Татьяна Сергеевна,Удаленная работа,Постоянная удаленка Познякова Татьяна Сергеевна,Все,Удаленная работа,,
Софьин Никита Сергеевич,Удаленная работа,Постоянная удаленка Чуб Александр Васильевич,Все,Удаленная работа,,
Чуб Александр Васильевич,Удаленная работа,Постоянная удаленка Шуличенко Иван Иванович,Все,Удаленная работа,,
Шуличенко Иван Иванович,Удаленная работа,Постоянная удаленка Пухаренко Юрий Владимирович,Все,Удаленная работа,,
Пухаренко Юрий Владимирович,Удаленная работа,Постоянная удаленка Пшеничный Виктор Петрович,Все,Удаленная работа,,
Пшеничный Виктор Петрович,Удаленная работа,Постоянная удаленка Незнанова Валерия Игоревна,Все,Удаленная работа,,
Незнанова Валерия Игоревна,Удаленная работа,Постоянная удаленка Ковалев Владимир Владимирович,Все,Удаленная работа,,
Ковалев Владимир Владимирович,Удаленная работа,Постоянная удаленка Кожокарь Татьяна Юрьевна,Все,Удаленная работа,,
Кожокарь Татьяна Юрьевна,Удаленная работа,Постоянная удаленка Субетто Юлия Викторовна,Все,Удаленная работа,,
Субетто Юлия Викторовна,Удаленная работа,Постоянная удаленка Чуркина Елена Геннадьевна,Все,Удаленная работа,,
Ароян Станислав Месропович,Удаленная работа,Временная удаленка
1 fio department note reason date_from date_to
2 Королёва Наталья Александровна Все Постоянная удаленка Удаленная работа
3 Николаева Ирина Леонидовна Все Постоянная удаленка Удаленная работа
4 Познякова Татьяна Сергеевна Все Постоянная удаленка Удаленная работа
5 Софьин Никита Сергеевич Чуб Александр Васильевич Все Постоянная удаленка Удаленная работа
6 Чуб Александр Васильевич Шуличенко Иван Иванович Все Постоянная удаленка Удаленная работа
7 Шуличенко Иван Иванович Пухаренко Юрий Владимирович Все Постоянная удаленка Удаленная работа
8 Пухаренко Юрий Владимирович Пшеничный Виктор Петрович Все Постоянная удаленка Удаленная работа
9 Пшеничный Виктор Петрович Незнанова Валерия Игоревна Все Постоянная удаленка Удаленная работа
10 Незнанова Валерия Игоревна Ковалев Владимир Владимирович Все Постоянная удаленка Удаленная работа
11 Ковалев Владимир Владимирович Кожокарь Татьяна Юрьевна Все Постоянная удаленка Удаленная работа
12 Кожокарь Татьяна Юрьевна Субетто Юлия Викторовна Все Постоянная удаленка Удаленная работа
13 Субетто Юлия Викторовна Чуркина Елена Геннадьевна Все Постоянная удаленка Удаленная работа
Ароян Станислав Месропович Временная удаленка Удаленная работа
+229
View File
@@ -0,0 +1,229 @@
# Changelog
Все важные изменения проекта документируются в этом файле.
## [2.5.8] - 2026-09-10
### Добавлено (Added)
- **Двухконтурная физическая модель СКУД (Правый PERCo):**
- Поддержка проходов через оба турникета (`DoorIndex IN (1, 2)`) для сотрудников дворовых служб (отделы `ЭТО`, `ЛЦ` и др.).
- Реестр доступа к правому турникету в `exceptions_registry` с категориями `turnstile_fio` и `turnstile_departments`.
- Корректная склейка ФИО (`Name + FirstName + MidName`) в динамическом SQL-фильтре MS SQL.
- **Интерактивное модальное окно исключений:**
- Замена браузерного `prompt()` на модальную форму в едином Tailwind-стиле с автоподбором сотрудников из 1С:ЗУП (`staff-autocomplete`).
- Поле примечания/комментария для фиксации оснований допуска.
- **Точное локальное время создания срезов:**
- Явная фиксация времени хоста при записи срезов в `scud_logs.created_at`, устраняющая 3-часовое смещение UTC внутри контейнеров.
- **Декларативное подтверждение удаления срезов:**
- Добавление параметра `confirmed: boolean` в схему `TOOLS_SCHEMA` инструмента `db_delete_snapshots`, исключающее зацикливание подтверждений в LLM.
### Изменено (Changed)
- **Упразднение искусственного автозакрытия смен:**
- Полное отключение механизма `calculate_autoclose_time` по согласованию с отделом кадров. При отсутствии физической отметки на турникете статус «Нет выхода» сохраняется без искажения аналитики.
- **Математический расчет временных границ срезов:**
- Перевод формирования `@EndDate` в `services/scud_export.py` на цепочку функций `DATEADD` от `@StartDate`, устраняющий зависимость от регионального формата даты (`DMY` vs `MDY`).
## [Unreleased] - 2026-09-07
### Добавлено
- Механизм Gemini-скроллинга диалогового окна: функция `scrollToUserMessageTop` в `core.js` автоматически поднимает отправленный вопрос к верхнему срезу экрана, скрывая громоздкие предыдущие ответы.
- CSS-отступ `padding-bottom: 80vh` у `#chat-messages-container`, гарантирующий свободное вертикальное пространство для прокрутки без сдвига строки ввода.
- Пункт 2.9 системного промпта, жестко обязывающий модель вызывать нативный инструмент `db_get_rules` при запросах Базы Знаний и регламентов вместо текстовой имитации.
### Запланировано
- Рефакторинг интерпретации аппаратных кодов турникетов PERCO в `services/scud_export.py`: разделение логики разрешения прохода (`Event 28`) и факта проворота створки (`Event 32`), приоритет направления по `log.Mode` (1 — вход, 2 — выход) для устранения фантомных выходов.
- Создание DDL-схемы и таблицы `scud_events_raw` в SQLite для гранулярного хранения всех внутридневных перемещений сотрудников (база для учета перекуров и инструмента «Кто в здании»).
## [0.9.5] - 2026-08-28
### Добавлено
- Механизм отказоустойчивого сохранения отчетов `safe_close_workbook` в `services/excel_exporter.py`: при блокировке файла открытым процессом Excel создается резервный файл с таймстемпом вида `..._timestamp.xlsx`.
### Изменено
- Экспорт всех Excel-документов (`services/scud_export.py`, `services/excel_exporter.py`) полностью переведен с `openpyxl` на `xlsxwriter`.
- Скорректирована верстка детального отчета: столбец «Подразделение» выровнен строго по центру, колонка «ФИО» расширена на 10% (ширина 33), а избыточная ширина столбцов «Первая активность» и «Отклонение от нормы» уменьшена до 11 пунктов для корректного размещения на экране без горизонтальной прокрутки.
- В модуле сетевой синхронизации `services/share_copier.py` вызов `shutil.copy2` заменен на `shutil.copyfile`, что устранило ошибки метаданных `[Errno 1] Operation not permitted` при монтировании SMB-шары.
### Исправлено
- Устранена системная ошибка структуры OpenXML (`errorXXXXXX_01.xml`, «Обнаружена ошибка в части содержимого книги...»), возникавшая при открытии сгенерированных отчетов в MS Excel.
- Восстановлены сворачиваемые интерактивные категории с кнопками `[+]` / `[-]` в файле ежедневной сводки (`outline_settings(symbols_below=False)`).
## [Unreleased] - 2026-08-28
### Fixed
- **Генерация Excel-книг (`services/excel_exporter.py`, `services/scud_export.py`):** полный перевод экспорта с `openpyxl` на `xlsxwriter`, что устранило хроническую ошибку структуры OpenXML («В книге обнаружено нечитаемое содержимое...»).
- **Группировки в Сводке:** восстановлено корректное отображение интерактивных кнопок `[+]`/`[-]` и сворачивание категорий по умолчанию (`outline_settings`, `level=1`, `collapsed=True`).
- **Верстка и пропорции Детального отчета:** центрирование столбца «Подразделение», калибровка ширины колонок «ФИО» (+10%), «Первая активность» и «Отклонение от нормы» для плотного размещения таблицы на одном экране без горизонтальной прокрутки.
- **Безопасное сохранение при блокировках:** внедрена функция `safe_close_workbook` для фонового сохранения резервных копий с timestamp, если файл открыт пользователем в Excel.
- **Сетевое копирование 1С (`services/share_copier.py`):** замена `shutil.copy2` на `shutil.copyfile` для устранения сбоя `[Errno 1] Operation not permitted` при работе с SMB/CIFS-шарой.
## [Unreleased] - 2026-08-28
### Fixed
- **Инкремент Snapshot ID (`core/repositories/scud_repo.py`):** заменена строковая сортировка `ORDER BY snapshot_id DESC` на целочисленный парсинг суффиксов, что устранило залипание номеров снапшотов (`-002`).
### Added
- **Режим тихих почасовых срезов (`--export-only`):** быстрый экспорт данных СКУД напрямую в SQLite без построения отчетов для интеграции в cron.
- **Интерактивный справочник CLI (`main_etl.py`):** полный баннер справки по всем аргументам (`-h`, `--help`, `help`).
- **Автоматическая ротация промежуточных срезов (`services/snapshots/retention.py`):** ночная очистка устаревших почасовых срезов старше 2 дней с сохранением полуденного (`13:00`) и итогового `Y` (`23:59:59`).
## [Unreleased] - 2026-08-28
### Added
- **Smart Snap-to-Grid Finder (`services/snapshots/finder.py`):** интеллектуальный поиск ближайшего среза СКУД в SQLite с допуском ±20 минут и поддержкой On-Demand экспорта из MS SQL при отсутствии готового среза.
- **Параметр `--time` в `main_etl.py`:** возможность формирования оперативной сводки на произвольное время суток.
- **Раздельные генераторы отчетов:**
- `services/scud_etl/svodka_generator.py` — оперативная сводка за сегодня с группировками.
- `services/scud_etl/otchet_generator.py` — суточный детальный отчет за вчера по финишному срезу `Y` (23:59:59) с учетом рабочего норматива 8.5 ч.
### Changed
- **Фиксация финишного среза `Y` на `23:59:59`:** обновлено время снапшота в `services/scud_export.py`, `core/repositories/scud_repo.py` и `services/scud_etl/pipeline.py` с сохранением обратной совместимости со срезами на `22:00:00`.
- **Путь виртуального окружения в `run_cron_etl.sh`:** скорректирован на актуальный `/home/puh/projects/scud_ai/venv/bin/python`.
- **Игнорирование временных файлов SQLite:** добавлены `*.db-shm` и `*.db-wal` в `.gitignore`.
## [3.2.0] — 2026-08-27
### 🧠 Интеллектуальный арбитраж личностей, агрегация мульти-пропусков и автозакрытие смен
#### ✨ Добавлено:
- **Кэш сопоставлений личностей (`person_identity_mapping`)**:
- Создана таблица в SQLite (`core/schema.py`) для постоянного хранения подтвержденных связок между сырыми ФИО СКУД и эталонными ФИО 1С:ЗУП.
- Поддержка мгновенного извлечения проверенных сопоставлений в `merger.py` (время выборки < 1 мс).
- **Агрегация дубликатов пропусков и мульти-учеток СКУД (`merger.py`)**:
- Функция `aggregate_scud_by_person` объединяет все карты/записи одного физлица: фиксирует самый ранний вход `min(valid_ins)`, самый поздний выход `max(valid_outs)` и итоговый статус присутствия.
- Исключено ложное попадание сотрудников с несколькими пропусками (например, Кондрашова Е.В.) в категорию «неизвестно».
- **Интеллектуальный выбор ставки совместителей 1С (`merger.py`)**:
- Функция `select_best_zup_position` сопоставляет множественные должности одного сотрудника в 1С со СКУД и подтягивает ставку того подразделения, где человек находится физически.
- **Автозакрытие незакрытых смен по Правилу 8.5ч (`excel_exporter.py`)**:
- При наличии утреннего входа и отсутствии вечернего выхода у офисных сотрудников в детальном отчете за вчера расчетный выход ставится как `Вход + 8ч 30мин`, норма 8 часов и нулевое отклонение `0:00` (вместо ложного штрафа `-8:00`).
- **Теневой ИИ-арбитраж нераспознанных ФИО (`text_reporter.py`)**:
- Модуль `find_ai_identity_suggestions` выявляет нечеткие совпадения и опечатки операторов СКУД, выводя рекомендации для кадровой службы в блоке «💡 Предложения ИИ по сопоставлению ФИО».
- **Расширение CLI-утилиты (`scripts/db_cli.py`)**:
- Добавлены команды управления связками: `python scripts/db_cli.py mapping [list|add|del]`.
#### 🔧 Изменено:
- **`services/scud_etl/anomaly_detector.py`**:
- Добавлено авто-детектирование задвоенных карточек СКУД (`DUPLICATE_SCUD_CARD`) с передачей информации в ИИ-сводку.
- **`main_etl.py`**:
- Восстановлена передача сырых датасетов (`raw_scud_df`, `raw_staff_df`, `raw_absent_df`) в ИИ-аудитор и генерация диагностического дампа `СКУД_Сырые_данные_ДД.ММ.ГГГГ.xlsx`.
---
## [3.1.0] — 2026-08-25
### 🛡️ Реляционный реестр исключений, Белый список и отказоустойчивость выгрузок
#### ✨ Добавлено:
- **Реляционный реестр исключений (`exceptions_registry`)**:
- Перенос исключений (`departments`, `positions`, `fio`, `position_keywords`) и белого списка (`include_fio`) из статического JSON в базу данных SQLite (`core/schema.py`).
- Репозиторий `services/exceptions_repo.py` с функциями CRUD и авто-миграцией из `exceptions.json`.
- **Белый список (`include_fio`)**:
- Поддержка явного включения сотрудников в расчет контроллинга в обход должностных и отдельских фильтров (Тарасенко А.А., Журиков М.Н.).
- **Управление исключениями в CLI (`scripts/db_cli.py`)**:
- Добавлены команды `exceptions list`, `exceptions add -c ... -v ...`, `exceptions del`, `exceptions sync`.
- **Резервный загрузчик кадровых отсутствий (`data_loader.py`)**:
- Реализован каскадный fallback: `MS SQL 1C:ЗУП` (таймаут 30с) $\rightarrow$ резервный парсинг скопированных Excel-файлов `Отсутствия_*.xlsx` $\rightarrow$ `static_reason_workers.csv`.
#### 🔧 Изменено:
- **Структура отчетов Excel (`excel_exporter.py`)**:
- Изменен базовый путь сохранения отчетов на `output/reports/ГОД/МЕСЯЦ/`.
- В Ежедневной сводке блок «В том числе на удаленной работе» вынесен под «Итого на работе», а блок «Исключения» размещен в самом низу с поддержкой группировки.
- Добавлена защита от блокировок Excel (`PermissionError`/`OSError`): при занятости файла создается резервная копия с временной меткой.
- **Подключение к СКУД (`services/scud_export.py`)**:
- Актуализирован адрес сервера MS SQL Орион (`172.16.200.147\SQL`).
- Добавлена защита инициализации файлового логгера от ошибок доступа прав пользователей.
---
## [3.0.0] — 2026-08-21
### 🚀 Глубокий архитектурный рефакторинг (Unified Clean Architecture)
#### Добавлено:
- **Пакет ядра `core/`:**
- `core/connection.py`: централизованный менеджер SQLite-подключений с поддержкой WAL-режима и row_factory.
- `core/schema.py`: единая точка инициализации схемы данных и индексов.
- `core/repositories/`: низкоуровневые репозитории `scud_repo.py` и `zup_repo.py`.
- **Доменные сервисы `services/`:**
- `services/tasks/`: полная изоляция логики задач (CRUD, Markdown-экспорт, генерация `ROADMAP.md`).
- `services/prompts/`: доменный сервис системного промпта, поддержка превью, генерация HTML-Diff и пакетное удаление узлов (`BATCH_DELETE`).
- `services/snapshots/`: бизнес-логика срезов СКУД с защитой финишных Y-снапшотов.
- `services/knowledge/`: сервис базы знаний правил и синонимов отделов.
- `services/scud_etl/`: модульный ETL-конвейер (`pipeline.py`, `anomaly_detector.py`, `merger.py`, `sql_queries.py`).
- **ИИ-оркестратор `modules/ai_engine/`:**
- Декомпозиция монолитного `agent.py` на узкие обработчики `handlers/` и динамический контекст-билдер `context_builder.py`.
- Двухконтурная обработка: нативный Function Calling + семантический страхующий инжектор (`tool_injector.py`).
- Интеллектуальный Topic Drift менеджер с вежливым напоминанием на 3-й шаг и автоочисткой эфемерных сообщений на 4-й.
- **Интерфейс и UI:**
- Кнопка «✏️ Редактировать» прямо в карточке просмотра системного промпта с инлайн-редактором.
#### Исправлено:
- Устранена утечка системного промпта в постоянный контекст (`chat_messages`).
- Исправлены ошибки несоответствия сигнатур (`TypeError: create_prompt_preview`, `save_full_prompt_draft`, `get_snapshots_registry`) в Fast-Path.
- Устранена галлюцинация слов-паразитов («Почемучка», «Почемучто») на уровне валидации ответа.
- Исправлено устаревшее противоречивое правило 2.3 в базе данных, блокировавшее интерактивное удаление задач.
#### Удалено:
- Устаревшие файлы-дубли: `modules/web_api/llm/db/db_tasks.py`, `db_snapshots.py`, `prompt_merger.py`.
---
## [2026-08-19] — Инлайн-редактор задач, интеллектуальный ContextManager и стабилизация Function Calling
### ✨ Добавлено
* **Инлайн-редактирование задач (`task_widget.js` / `tasks.py`)**:
- Добавлена кнопка редактирования (`✏️`) на каждой карточке задачи.
- Карточка трансформируется в инлайн-форму с тремя полями: текст задачи, HTML5-пикер календаря планируемого срока и выпадающий список важности (`LOW`, `MEDIUM`, `HIGH`, `CRITICAL`).
- Добавлен REST API эндпоинт `PATCH /api/v1/tasks/{task_id}` с поддержкой комплексного обновления полей в SQLite.
- В карточки выведено отображение даты создания задачи.
* **Интеллектуальный ContextManager (`context_manager.py`)**:
- Служебные запросы и карточки инструментов сохраняются синхронными парами (`is_ephemeral = 1`) и атомарно удаляются при завершении операций.
- Свободные диалоги оператора (включая темы Topic Drift) сохраняются в постоянную историю (`is_ephemeral = 0`) и не стираются при закрытии сессий промпта/задач.
* **Поддержка Y-снапшотов (`db_snapshots.py`)**:
- Исправлен SQL-запрос `db_get_snapshots` для точного извлечения ночных и фоновых срезов с префиксом `Y` (например, `Y20260817-003`).
### 🔧 Изменено
* **`modules/web_api/llm/agent.py`**:
- Полностью удалены эвристические текстовые фильтры и принудительные сортировки инструментов.
- Настроен нативный возврат и исполнение инструментов без зацикливания модели.
- Устранена ошибка `UnboundLocalError` при вызове справочных инструментов.
* **`modules/web_api/llm/schemas.py`**:
- Очищены описания инструментов от агрессивных директив и капса, стабилизирована селективность Function Calling для Qwen 2.5.
* **`modules/web_api/llm/core/ollama_client.py`**:
- Оптимизированы параметры генерации для Function Calling (`repeat_penalty: 1.0`, `presence_penalty: 0.0`, `temperature: 0.0`), что исключило спам несколькими вызовами инструментов на один запрос.
* **`modules/web_api/static/js/chat/task_widget.js`**:
- Фильтр по умолчанию переведен на вкладку **«В работе»** (`IN_PROGRESS`).
---
## [2026-08-18] — Двухфазный жизненный цикл сессий, двусторонний Diff и Topic Drift Guard
### ✨ Добавлено
* **Двусторонний клиентский Diff (`core.js`)**: Реализован алгоритм сравнения черновика с baseline-промптом. Удаленные строки остаются на экране с красным зачеркиванием `[УДАЛЕНИЕ]`, а добавленные/измененные строки выделяются красной рамкой с авто-отступами.
* **Topic Drift Guard (`agent.py`)**: Внедрен двухуровневый контроль смены темы:
- Мгновенный сброс стейта и зачистка эфемерных сообщений при вызове сторонних инструментов (задачи, снапшоты, база).
- Мягкий счетчик отвлечений (`idle_turns` = 3) в свободном диалоге с выводом напоминания и кнопок из `tool_action_registry`.
* **Команда `context purge` в CLI (`db_cli.py`)**: Добавлен флаг `--all` для полной очистки таблицы `chat_messages` и сброса стейтов сессий.
---
## [2026-08-17] — Внедрение CLI-инспекции контекста и переход на нативный Function Calling
### ✨ Добавлено
* **`scripts/db_cli.py`**: Добавлена команда `context` для инспекции истории сообщений `chat_messages` по сессиям с визуальным отображением флагов `[ЭФЕМЕРНОЕ]`.
* **`ROADMAP.md`**: Сформирована актуальная дорожная карта с планом отказа от регулярных выражений в пользу нативного Function Calling.
---
## [2026-08-15] — Реляционная архитектура системного промпта
### ✨ Добавлено
* **`system_prompt_nodes`**: Создана таблица реляционных узлов промпта с индексацией по разделам и пунктам (`section_id`, `item_id`).
* **`db_prompt_node_edit`**: Добавлен инструмент точечного добавления, изменения и удаления пунктов системного промпта.
* **Инлайн-редактор**: Добавлен визуальный diff-просмотр изменений с подсветкой и кнопкой `[✏️ Редактировать]`.
---
## [2026-08-10] — Механизм эфемерных сообщений и очистки диалога
### ✨ Добавлено
* **Эфемерные сообщения (`is_ephemeral`)**: Маркировка временных сервисных ответов и черновиков.
* **`db_purge_ephemeral_messages`**: Механизм физической зачистки временных записей из SQLite при завершении операций.
* **Интерактивные виджеты**: Поддержка карточек задач (`TASK_INTERACTIVE_CARD`) с кнопками быстрого действия.
@@ -0,0 +1,185 @@
# **PROJECT BRAIN: SCUD Orion AI & Context API (Master Technical & Architectural Manifesto)**
**Версия документа:** 5.0 (Ultimate Developer & LLM Context Engine)
**Назначение:** Исчерпывающий контекстный дамп проекта для немедленного продолжения разработки в любой передовой LLM без потери исторического контекста, с полным пониманием бизнес-логики, схем баз данных, подводных камней и нерешенных задач.
## ---
**1\. Архитектурный паспорт и инфраструктурный профиль**
| Компонент | Параметры и стек |
| :---- | :---- |
| **Среда и ОС** | Linux Docker Home (Debian/Ubuntu), Python 3.11/3.12 в виртуальном окружении venv, Git/Gitea. Разработка ведется через VS Code Remote-SSH. |
| **Локальная СУБД проекта** | SQLite (data/scud\_orion\_ai.db), режим PRAGMA journal\_mode \= WAL, PRAGMA synchronous \= NORMAL, PRAGMA foreign\_keys \= ON. |
| **СКУД Источник (MSSQL)** | MS SQL Server 172.16.31.221\\SQL (база Orion-14.01.21-1), драйвер ODBC Driver 18 for SQL Server (TrustServerCertificate=yes, Encrypt=no). Доступ через pyodbc. |
| **1С:ЗУП 3.1 Источник (MSSQL)** | MS SQL Server ACCOUNT-01 (база ZUP30, пользователь scud\_reader). Прямое чтение таблиц dbo.\_Reference299 (сотрудники) и dbo.\_InfoRg16921 (состояния отсутствий). Резервная Samba-шара: /mnt/scud\_share. |
| **Локальный контур LLM** | Ollama на http://192.168.11.3:11434 (GPU NVIDIA RTX 5070 Ti 16GB). Модель qwen2.5:14b (основной агент, Function Calling, анализ аномалий), qwen2.5vl:7b-q8\_0 (OCR документов/картинок). |
| **Web API & Фронтенд** | FastAPI (Uvicorn), JWT (HS256, 30 дней), Passlib (bcrypt). Фронтенд: Single Page Application (HTML5, Tailwind CSS, Vanilla JS) с Generative UI виджетами. |
## ---
**2\. Энциклопедия подводных камней, инцидентов и принятых инженерных решений**
### **2.1. Галлюцинации LLM и переход к парадигме Python-Driven Diff**
> * **Проблема:** Попытка передать списки в 300 ФИО напрямую в промпт модели с командой «сравни списки» приводила к потере контекста, выдуманным опечаткам (*«ЛЕНМОРНИИПРОЕКТ ОЭС Варламова Мария»*) и плейсхолдерам ('Васильев Л.И.', ...).
> * **Решение:** Утверждена строгая гибридная схема:
1. **Детерминированный слой (Python / Pandas / SQL):** читает файлы, нормализует строки, агрегирует совмещения и мгновенно находит 95%+ точных соответствий.
2. **Интеллектуальный слой (Qwen 2.5:14b):** вызывается *только на спорный несовпавший остаток* (1–5 записей) в виде строгого JSON-интерфейса Entity Resolution.
### **2.2. Символьная нормализация и кадровые совмещения (Кейс Балаевой О.Г.)**
> * **Проблема символов:** Латинские омоглифы (буквы клавиатуры a, c, e, o, p, x, y вместо русских), разделители 1С в скобках ((осн.), (совм.), (внешн.)), неразрывные пробелы \\xa0, буква ё и случайный ввод цифры 0 вместо буквы О.
> * **Решение (normalize\_fio):** Очистка скобок через регулярное выражение re.sub(r'\\(.\*?\\)', '', fio), замена латиницы через str.maketrans('aceopxyABCEHKMOPTX', 'асеорхуАВСЕНКМОРТХ'), замена ё-\>е и удаление лишних пробелов.
> * **Коллизия 270 vs 271:** В СКУД 270 физических лиц (один пропуск на человека), в 1С — 271 строка. Сотрудник **Балаева Ольга Геннадьевна** числится на двух ставках в разных отделах (*Руководитель управления персоналом* и *Руководитель режимно-секретного отдела*).
> * **Решение:** Внедрена группировка датафрейма 1С по fio\_clean с агрегацией должностей и подразделений через разделитель " / ".
### **2.3. Защита от коллизий полных тёзок и ложных связок однофамильцев (Кейс Семеновых)**
> * **Инцидент:** Неограниченная строгими правилами LLM связала двух разных сотрудниц — *«Семенову Юлию Борисовну»* (работает) и *«Семенову Дину Дмитриевну»* (в отпуске), посчитав разные имена опечаткой из\-за одинаковой фамилии. В результате Юлии Борисовне ошибочно был приписан статус отпуска.
> * **Двухконтурный барьер:**
1. **В промпте:** Запрет объединения однофамильцев с разными именами/отчествами.
2. **В Python:** Проверка совпадения первых букв имени и отчества. При несовпадении сопоставление ИИ аннулируется с логированием ⚠️ \[Блокировка фейка\].
### **2.4. Вынесение исключений в exceptions.json и синонимы отделов**
> * Служебный персонал (отдел «ЭТО», уборщики производственных и служебных помещений, клинеры, дворники, гардеробщики) вынесен в exceptions.json.
> * Для сопоставления кратких аббревиатур СКУД и полных названий отделов 1С (*«ОВК» ⟷ «Отдел внутреннего контроля»*) создана таблица department\_synonyms с автоматическим самообучением при работе человека (Human-in-the-Loop).
### **2.5. Разгадка феномена «исчезающих неизвестных» и аппаратный сбой СКУД 04.08.2026**
> * **Аномалия:** Утром 04.08.2026 на главном КПП зависли контроллеры входа. У 119 сотрудников не записалось утреннее событие (time\_in \= 'Нет входа'). Из них 57 человек были в отпусках/командировках по 1С. Оставшиеся 74 человека были на работе без утренней отметки. Однако вечерняя сводка показала всего 4–7 «неизвестных».
> * **Причина:** Скрипт агрегации видел дневные перемещения (обед, внутренние двери, туалет, вечерний выход) и автоматически проставлял Статус \= 'Присутствовал' и is\_present \= 1, несмотря на то, что колонка Начало\_дня оставалась 'Нет входа'. В итоге 70 человек тихо ушли в «Итого на работе», а системный сбой оборудования был замаскирован.
> * **Архитектурный фикс:**
1. Введена колонка **first\_activity («Первая активность»)** — вычисление MIN(TimeVal) AS FirstRawEvent по таблице pLogData без учета направления двери.
2. Если time\_in \== 'Нет входа', но first\_activity \!= '—', записи ставится anomaly\_flag \= 'ANOMALY\_NO\_IN\_HAS\_ACTIVITY', а is\_present принудительно сбрасывается в 0\.
3. Внедрен модуль analyze\_scud\_mass\_failure\_ai(): если доля таких аномалий превышает 5% смены (\>10 чел.), ИИ генерирует системный алерт о массовом сбое контроллеров КПП.
> * **Кейс Равина В.Э.:** Главный специалист официально находился в основном отпуске по 1С, утреннего входа на КПП не было, но зафиксирована активность в 14:11:06 и вечерний выход в 17:18:38. Система зарегистрировала истинную кадровую аномалию (физическое присутствие при официальном отпуске).
### **2.6. Система сквозных датированных снапшотов (срезов)**
> * **Формат ID:** \[Y\]YYYYMMDD-NNN (например, Y20260804-001 для вчерашнего зафиксированного вечернего среза на 22:00 и 20260805-002 для дневного).
> * **Оптимизация:** Функция has\_yesterday\_final\_snapshot() проверяет наличие вчерашнего среза в SQLite: если он есть, повторный тяжелый запрос к MS SQL Орион пропускается.
> * **CLI-управление:** Поддержка запуска отчетов по любому историческому снапшоту через ключ \--snapshot \[ID\] и пропуск выгрузки через \--skip-export.
### **2.7. Web API, Generative UI и управление жизненным циклом сессий (Topic Drift & Ephemeral Buffer)**
> * **Проблема засорения контекста LLM:** Вывод объемных списков задач, системных промптов и таблиц срезов забивал окно диалога, вызывая галлюцинации и «залипание» модели на старых данных.
> * **Решение:**
* В chat\_messages введен флаг is\_ephemeral \= 1 для временных сообщений.
* Двухфазные сессии (PROMPT\_PREVIEW → PROMPT\_FOLLOWUP) и детерминированный счетчик idle\_turns.
* **Context Guard:** На 3-м шаге отвлечения оператора на сторонние темы система отвечает на вопрос и выводит интерактивное напоминание с кнопками \[Завершить настройку\] / \[Показать промпт\]. При idle\_turns \> 3 происходит автоочистка сессии вызовом db\_purge\_ephemeral\_messages().
* **Fast-Path:** Прямой перехват команд подтверждения («да», «подтверждаю») и выхода («нет, спасибо») без обращения к LLM (ответ за 0.05 сек).
* **Декларативный реестр (tool\_action\_registry):** Хранение шаблонов ответов, кнопок и настроек эфемерности в SQLite.
> * **Generative UI (Задачи):** Инструмент db\_get\_tasks возвращает TASK\_INTERACTIVE\_CARD, рендерящий на клиенте живой виджет задач с табами (Все / В работе / В планах / Завершенные), чекбоксами и inline-созданием задач через REST API.
## ---
**3\. Полная схема базы данных SQLite (data/scud\_orion\_ai.db)**
| Таблица | Назначение и бизнес-логика | Структура полей |
| :---- | :---- | :---- |
| **scud\_logs** | Логи проходов СКУД, расчетное время в здании и снапшоты. | id INTEGER PK AUTOINCREMENT, log\_date TEXT, fio TEXT, fio\_clean TEXT, department TEXT, position TEXT, time\_in TEXT, first\_activity TEXT DEFAULT '—', time\_out TEXT, time\_in\_building TEXT, is\_present INTEGER, anomaly\_flag TEXT DEFAULT 'NONE', snapshot\_time TEXT, snapshot\_id TEXT, created\_at TIMESTAMP |
| **zup\_staff** | Штатное расписание 1С:ЗУП на даты срезов. | id INTEGER PK AUTOINCREMENT, snapshot\_date TEXT, fio TEXT, fio\_clean TEXT, department TEXT, position TEXT, created\_at TIMESTAMP |
| **zup\_absences** | Документы отклонений 1С (отпуска, больничные, командировки, декреты). | id INTEGER PK AUTOINCREMENT, absence\_date TEXT, fio TEXT, fio\_clean TEXT, absence\_type TEXT, created\_at TIMESTAMP |
| **anomalies\_history** | Журнал выявленных кадровых и аппаратных аномалий. | id INTEGER PK AUTOINCREMENT, anomaly\_date TEXT, fio TEXT, anomaly\_type TEXT, details TEXT, human\_status TEXT DEFAULT 'Pending', created\_at TIMESTAMP |
| **ai\_knowledge\_base** | Глобальные правила арбитража и приоритеты компании для ИИ. | id INTEGER PK AUTOINCREMENT, rule\_text TEXT UNIQUE, added\_by TEXT DEFAULT 'Human', created\_at TIMESTAMP |
| **system\_prompts** | Версионные системные промпты агента (активный по is\_active=1). | id INTEGER PK AUTOINCREMENT, name TEXT, prompt\_text TEXT, is\_active INTEGER DEFAULT 1, updated\_at TIMESTAMP |
| **tool\_action\_registry** | Декларативный реестр шаблонов, кнопок и настроек эфемерности инструментов. | id INTEGER PK AUTOINCREMENT, tool\_name TEXT UNIQUE, category TEXT, bypass\_llm INTEGER, success\_template TEXT, follow\_up\_question TEXT, action\_type TEXT, buttons\_json TEXT, is\_active INTEGER, is\_ephemeral INTEGER, updated\_at TIMESTAMP |
| **session\_states** | Активные состояния сессий, черновики и счетчик idle\_turns. | session\_id TEXT PRIMARY KEY, state\_type TEXT, pending\_data TEXT, updated\_at TIMESTAMP |
| **chat\_messages** | История диалогов с поддержкой разделения на постоянные и эфемерные сообщения. | id INTEGER PK AUTOINCREMENT, session\_id TEXT, role TEXT, content TEXT, is\_ephemeral INTEGER DEFAULT 0, created\_at TIMESTAMP |
| **tasks** | Реестр задач операторов с привязкой к user\_id. | id INTEGER PK AUTOINCREMENT, task\_id TEXT, module TEXT, title TEXT, priority TEXT, status TEXT, due\_date TEXT, user\_id INTEGER, created\_at TIMESTAMP |
| **users** | Учетные записи операторов (хэши паролей bcrypt, права администратора). | id INTEGER PK AUTOINCREMENT, username TEXT UNIQUE, password\_hash TEXT, is\_admin INTEGER, full\_name TEXT, created\_at TIMESTAMP |
| **department\_synonyms** | База соответствия аббревиатур СКУД полным названиям отделов 1С. | id INTEGER PK AUTOINCREMENT, short\_name TEXT UNIQUE, full\_name TEXT, created\_at TIMESTAMP |
| **system\_reference** | Справочник команд и примеров промптов для операторов. | id INTEGER PK AUTOINCREMENT, category TEXT, title TEXT, example\_prompt TEXT, description TEXT |
## ---
**4\. Архитектурная структура каталогов**
scud\_ai/
├── config.py \# Конфигурация: пути, расчет дат, URL Ollama, параметры MS SQL
├── exceptions.json \# Декларативные фильтры исключений (ОВК, уборщики, подрядчики)
├── main\_etl.py \# Оркестратор ETL: экспорт СКУД/1С \-\> SQLite \-\> Excel/MD отчеты
├── project\_code\_snapshot.md \# Полный монолитный исходный код всех файлов репозитория
├── core/
│ └── database.py \# Драйвер SQLite (WAL), функции сохранения/загрузки срезов
├── services/
│ ├── scud\_export.py \# Прямой SQL-экспорт из MS SQL «Орион Pro»
│ ├── zup\_extractor.py \# Прямой SQL-экспорт из MS SQL 1С:ЗУП 3.1
│ ├── data\_loader.py \# Загрузка данных, нормализация ФИО, расчет первой активности
│ ├── data\_validator.py \# Проверка свежести и наличия файлов за Вчера и Сегодня
│ ├── excel\_exporter.py \# Генерация сводки и детального отчета в Excel (openpyxl)
│ ├── text\_reporter.py \# Построение итогового Markdown-отчета через Qwen 2.5
│ ├── ai\_verifier.py \# Модули ИИ-аудита, Fuzzy Matching и детекции сбоев СКУД
│ ├── feedback\_loop.py \# Интерактивный консольный модуль Human-in-the-Loop
│ ├── knowledge\_base.py \# Интерфейс работы с базой знаний правил
│ └── share\_copier.py \# Резервное скачивание файлов 1С с шары /mnt/scud\_share
├── scripts/
│ ├── db\_cli.py \# CLI-утилита управления БД, снапшотами, дампами и сессиями
│ ├── fix\_snapshots.py \# Скрипт переиндексации и нормализации снапшотов
│ ├── init\_tool\_registry.py \# Инициализация декларативного реестра инструментов
│ └── diagnostics/
│ ├── inspect\_db.py \# Диагностика структуры таблиц и количества записей
│ ├── inspect\_ephemeral.py \# Анализ распределения постоянных и эфемерных сообщений
│ ├── inspect\_files.py \# Аудит файлов и размеров на диске
│ └── show\_tree.py \# Вывод дерева каталогов
├── modules/web\_api/
│ ├── main.py \# Точка входа FastAPI приложения, роутеры и раздача статики
│ ├── routers/ (admin, auth, chat, tasks) \# REST эндпоинты
│ ├── llm/
│ │ ├── agent.py \# Координатор диалога, Tool Calls и Topic Drift Guard
│ │ ├── schemas.py \# Описание JSON-схем инструментов для Ollama
│ │ ├── file\_parser.py \# Мультимодальный парсер файлов (PDF, PNG, XLSX, TXT)
│ │ ├── db\_tools.py \# Фасадные вызовы функций БД для агента
│ │ ├── core/ (calendar\_utils, fast\_path, ollama\_client, prompt\_merger, tool\_injector)
│ │ └── db/ (connection, db\_chat, db\_prompts, db\_snapshots, db\_tasks)
│ └── static/ (index.html, css/styles.css, js/app.js, js/auth.js, js/chat.js, js/tasks.js)
└── docs/
└── development\_roadmap.md \# Стратегическая дорожная карта и архитектурный бэклог
## ---
**5\. Справочник команд CLI (HOWTO)**
### **ETL Пайплайн (main\_etl.py):**
> * python main\_etl.py — стандартный дневной запуск (экспорт из MS SQL, сохранение снапшота, генерация отчетов).
> * python main\_etl.py \--skip-export — построение отчетов по последнему имеющемуся снапшоту из SQLite без повторного запроса к MS SQL.
> * python main\_etl.py \--snapshot 20260805-001 — точный расчет отчетов по указанному составному ID снапшота.
> * python main\_etl.py \-d — запуск в режиме расширенной отладки (DEBUG).
### **CLI базы данных (scripts/db\_cli.py):**
> * python scripts/db\_cli.py stats — статистика по количеству строк во всех таблицах БД.
> * python scripts/db\_cli.py snapshots \[ДД.ММ.ГГГГ\] — просмотр реестра снапшотов с сортировкой по времени создания (свежие сверху).
> * python scripts/db\_cli.py scud \[ДД.ММ.ГГГГ\] \[--snapshot ID\] \[--export-xlsx NAME\] — инспекция логов СКУД и экспорт в Excel.
> * python scripts/db\_cli.py absences \[ДД.ММ.ГГГГ\] — список кадровых отклонений из 1С:ЗУП.
> * python scripts/db\_cli.py anomalies — история выявленных аномалий.
> * python scripts/db\_cli.py rules — правила базы знаний компании.
> * python scripts/db\_cli.py prompts — системные промпты из system\_prompts.
> * python scripts/db\_cli.py tools — декларативный реестр действий инструментов и кнопок.
> * python scripts/db\_cli.py sessions — активные сессионные стейты и висящие черновики.
> * python scripts/db\_cli.py snapshot del \[ID\] — удаление конкретного снапшота по ID.
> * python scripts/db\_cli.py snapshot del \--day \[ДД.ММ.ГГГГ\] — удаление всех снапшотов за день.
> * python scripts/db\_cli.py dump \[backup.xlsx\] — полный дамп всех таблиц базы данных в многостраничный Excel.
## ---
**6\. Стратегическая дорожная карта и нерешенные задачи (Roadmap & Backlog)**
> 1. **Масштабирование механизма эфемерного контекста на все инструменты (Rollout Roadmap):**
* **Снапшоты (snapshots):** Перевод вывода объемных таблиц срезов в эфемерный режим (is\_ephemeral \= 1), внедрение интерактивного подтверждения при удалении снапшотов по ID/дате через состояния SNAPSHOTS\_PREVIEW / SNAPSHOTS\_FOLLOWUP.
* **База знаний и аномалии (kb & anomalies):** Вывод справочников команд, правил арбитража и списков аномалий исключительно во временный слой для исключения «залипания» модели на устаревших правилах из истории сообщений.
* **Декларативная унификация в tool\_action\_registry:** Расширение схемы таблицы полями followup\_state\_type, auto\_purge\_on\_exit, guard\_question\_template для устранения хардкода имен функций в agent.py.
> 2. **Модуль анализа внутридневных перемещений («Контроль перекуров»):**
* Анализ интервалов между событиями OUT (выход) и IN (возврат) по журналу pLogData.
* Подсчет суммарного времени нахождения вне рабочего места в течение смены с отсечением технологической нормы (30–40 минут).
* Вывод аналитического блока и злостных нарушителей в отдельный лист Excel-отчета и итоговую Markdown-сводку.
> 3. **Интерактивный Task Planner & Roadmap Engine:**
* Специализированный инструмент для ведения динамических проектных планов и чек-листов с чекбоксами (\[ \] / \[x\]) прямо в диалоге с синхронизацией в таблицу tasks.
> 4. **Изолированная среда исполнения кода (Code Execution Sandbox):**
* Безопасный изолированный Docker/gVisor контейнер для динамического выполнения генерируемого моделью Python/Pandas кода на лету с возвратом stdout/stderr и графиков в контекст.
> 5. **Автоматизация расписания срезов и Telegram-алерты:**
* Автозапуск выгрузок в 12:00, 17:00 и 19:00 через systemd-таймеры с фиксацией snapshot\_time.
* Отправка мгновенных алертов о массовых сбоях турникетов (\>5%) и критических кадровых конфликтах дежурному системному администратору в Telegram.
@@ -0,0 +1,223 @@
# **SCUD Orion AI — Полная энциклопедическая хроника, архитектурный паспорт, разбор всех инцидентов и технический контекст проекта**
**Версия документа:** 4.0 (Исчерпывающий монолитный контекстный паспорт для новой сессии)
**Стек технологий и инфраструктура:** Python 3.11+, FastAPI (Uvicorn), SQLite (WAL mode, PRAGMA synchronous=NORMAL, внешние ключи ON), Ollama REST API (Qwen 2.5:14b — основной текстовый агент и классификатор, Qwen 2.5-VL:7b-q8\_0 / Qwen 3-VL:8b — OCR и зрение), MS SQL Server 2019/2022 (СКУД «Орион Pro» ЗАО НВП «Болид», 1С:ЗУП 3.1 через pyodbc / ODBC Driver 18), сетевая Samba-шара (/mnt/scud\_share), Git/Gitea, HTML5 / Vanilla JS / Tailwind CSS.
## ---
**1\. Исходный бизнес-контекст, роли систем и фундаментальные задачи**
Проект решает комплексную проблему контроллинга трудовой дисциплины, автоматического сопоставления кадровых фактов и защиты от рассинхронизации мастер-данных на предприятии со штатом \~270–276 сотрудников.
### **Источники первичных данных:**
> * **СКУД «Орион Pro» (Болид):** База данных MS SQL Server (SERVER: 172.16.31.221\\SQL, DB: Orion-14.01.21-1). Основные таблицы:
* pLogData — журнал всех сырых физических транзакций турникетов, калиток и внутренних дверей. Ключевые поля: TimeVal (время сервера), DeviceTime (время контроллера), HozOrgan / Hrk (ID сотрудника), Event (код события: 26, 28, 32 — вход; 27, 29, 33 — выход; 54, 55, 64, 65 — внутренние считыватели), Mode (направление: 1 — IN, 2 — OUT), ReaderIndex, DoorIndex.
* pList — реестр физических лиц и пропусков (ФИО, табельный номер, статус архива).
* PDivision, PPost — справочники подразделений и должностей СКУД.
> * **1С:ЗУП 3.1 (Кадры):** База данных MS SQL Server (SERVER: ACCOUNT-01, DB: ZUP30).
* dbo.\_Reference299 — эталонный справочник сотрудников.
* dbo.\_InfoRg16921 — периодический регистр сведений состояний сотрудников (отпуска основные, больничные, командировки, декретные отпуска, отпуска без сохранения оплаты).
* Файловые выгрузки с сетевой шары /mnt/scud\_share: файлы Штат\_ДД\_ММ\_ГГГГ.xlsx (шапка 7–8 строк) и Отсутствия\_ДД\_ММ\_ГГГГ.xlsx (шапка 3–4 строки).
> * **Реестры статических исключений:** static\_reason\_workers.csv (реестр удаленщиков и постоянных разъездных сотрудников), exceptions.json (декларативный фильтр обслуживающего персонала и спец-отделов).
## ---
**2\. Подробнейшая пошаговая хронология разработки, эволюция идей и разбор ошибок**
### **Фаза 1: Первые эксперименты с LLM, выявление галлюцинаций и гибридная парадигма**
> * **Попытка передачи сырых списков в чат:** Первоначальная попытка скормить модели Qwen 2.5:14b список из сотен ФИО с инструкцией *«запомни список 1С»* показала, что у LLM нет постоянной памяти между сессиями. Модель теряла контекст, путала фамилии и выдавала ответы с троеточиями ('Васильев Л.И.', ...) или придумывала несуществующие строки (*«ЛЕНМОРНИИПРОЕКТ ОЭС Варламова Мария»*).
> * **Ошибки openpyxl vs pandas:** Первая генерация кода моделью использовала библиотеку openpyxl с построчным перебором ячеек, жестко зашитыми опечатками (поиск слова 'фиио') и падением по FileNotFoundError.
> * **Переход к детерминированному пайплайну:** Принято ключевое архитектурное решение — **LLM не считает математику и не фильтрует 300 строк текста**. Всю механическую работу (ETL, слияние, фильтрацию) выполняет быстрый детерминированный Python (pandas, SQL), а локальная LLM выступает исключительно в роли «умного ревизора» на этапе сопоставления сомнительного несовпавшего остатка (Diff).
### **Фаза 2: Нормализация ФИО, кодировки и коллизия совместителей (Кейс Балаевой О.Г.)**
> * **Символьные ловушки:**
* Латинские буквы-двойники (омоглифы a, c, e, o, p, x, y, случайно набранные в английской раскладке).
* Служебные суффиксы в скобках, добавляемые 1С ((осн.), (совм.), (внешн.)).
* Буквы ё/е, двойные пробелы, неразрывные пробелы \\xa0 и случайный ввод цифры 0 вместо буквы О.
> * **Функция normalize\_fio():** Разработан строгий конвейер очистки:
> `def normalize_fio(fio):`
> `if not fio or not isinstance(fio, str): return ""`
> `fio_clean = re.sub(r'\(.*?\)', '', fio) # Удаление скобок`
> `fio_clean = fio_clean.replace('\xa0', ' ')`
> `trans = str.maketrans('aceopxyABCEHKMOPTX', 'асеорхуАВСЕНКМОРТХ')`
> `fio_clean = fio_clean.translate(trans)`
> `fio_clean = fio_clean.replace('ё', 'е').replace('Ё', 'Е')`
> `return " ".join(fio_clean.strip().split()).title()`
> * **Коллизия 270 vs 271:** В СКУД числилось 270 уникальных человек, а в 1С — 271 строка. Ручной разбор показал, что **Балаева Ольга Геннадьевна** занимает две ставки в 1С (*Руководитель управления персоналом* и *Руководитель режимно-секретного отдела*) при одном физическом пропуске в СКУД.
> * **Решение:** Внедрена обязательная группировка штата 1С по fio\_clean с объединением должностей и подразделений через " / ".
### **Фаза 3: Защита Entity Resolution от однофамильцев (Инцидент с Семеновыми)**
> * **Сбой модели:** При передаче несовпавших ФИО в Qwen 2.5 модель ошибочно сопоставила *«Семенову Юлию Борисовну»* (которая была на работе) с *«Семеновой Диной Дмитриевной»* (находившейся в отпуске), посчитав это опечаткой в имени из\-за одинаковой фамилии. В результате Юлия Борисовна получила статус нарушителя («в отпуске, но пришла»).
> * **Двухконтурная защита:**
1. В системный промпт ИИ зашито категорическое правило: однофамильцы с разными именами/отчествами — строго разные люди, объединение запрещено.
2. В Python встроен дублирующий барьер: если фамилии совпадают, но инициалы/первые буквы имен различаются, связь ИИ принудительно аннулируется с логированием ⚠️ \[Блокировка фейка\].
### **Фаза 4: Декларативные исключения и синонимы отделов**
> * **Переход к exceptions.json:** Исключение клининга, дворников, гардеробщиков и хозяйственного персонала отдела «ЭТО» вынесено из кода в конфигурационный файл exceptions.json (поддержка списков departments, positions, fio, position\_keywords).
> * **База синонимов отделов (department\_synonyms):** Для сопоставления сокращений СКУД с длинными названиями 1С (например, *«ОВК» ⟷ «Отдел внутреннего контроля»*) создана таблица SQLite, автоматически обучаемая при подтверждениях оператора.
### **Фаза 5: Аппаратный сбой СКУД 04.08.2026, разгадка «исчезающих неизвестных» и Первая активность**
> * **Анатомия сбоя:** Утром 04.08.2026 зависли регистраторы входа на главном КПП. У 119 сотрудников не записалось событие входа (time\_in \= 'Нет входа'). Из них 57 человек были официально закрыты документами 1С (отпуска, командировки). Оставшиеся \~74 человека реально пришли на работу, но утренней отметки не имели. Однако вечером сводка выдала всего 4–7 «неизвестных».
> * **Вскрытие механизма ошибки:** Было обнаружено, что скрипт агрегации видел дневные перемещения сотрудников (выходы на обед, считыватели туалетов, внутренние двери) и автоматически выставлял Статус \= 'Присутствовал' и is\_present \= 1\. Из-за этого люди попадали в категорию «Итого на работе», а массовый аппаратный сбой скрывался.
> * **Внедрение «Первой активности»:**
1. В базу данных и выгрузки Excel добавлена колонка **first\_activity («Первая активность»)** — минимальное время любого события дня в здании (MIN(TimeVal) AS FirstRawEvent без учета направления двери).
2. Если time\_in \== 'Нет входа', но first\_activity \!= '—', записи присваивается anomaly\_flag \= 'ANOMALY\_NO\_IN\_HAS\_ACTIVITY', а is\_present принудительно сбрасывается в 0\.
3. Внедрен модуль analyze\_scud\_mass\_failure\_ai(): при превышении порога в 5% смены (\>10 человек) ИИ поднимает тревогу о массовом сбое контроллеров КПП.
> * **Кейс Равина В.Э.:** Сотрудник находился в официальном отпуске, утреннего входа не имел, но пришел после обеда (первая активность 14:11:06, выход 17:18:38). Система корректно зафиксировала истинную кадровую аномалию (физическое присутствие при официальном отпуске).
### **Фаза 6: Архитектурная миграция под Gitea и система срезов (снапшотов)**
> * **Безопасный рефакторинг:** Монолит разделен на слои (core/, services/, scripts/, modules/web\_api/) через git mv с сохранением истории коммитов в ветке refactor/modular-structure.
> * **Формат ID снапшота:** Введена схема \[Y\]YYYYMMDD-NNN (например, Y20260804-001 для вчерашнего зафиксированного вечернего среза на 22:00 и 20260805-002 для дневного).
> * **Оптимизация экспорта:** Если в SQLite уже зафиксирован вчерашний срез с префиксом Y, повторный тяжелый SQL-запрос к СКУД Орион не выполняется (функция has\_yesterday\_final\_snapshot).
> * **Инспекция и CLI:** Написан инструмент scripts/db\_cli.py с командами stats, snapshots, scud, absences, anomalies, rules, prompts, tools, sessions, dump, snapshot del.
### **Фаза 7: Web API, Generative UI, управление памятью LLM и Topic Drift Guard**
> * **FastAPI & Безопасность:** Развернут Web API с JWT-авторизацией (bcrypt), изолированными задачами пользователей (tasks с привязкой к user\_id), гостевым режимом и ролевой моделью.
> * **Проблема деградации контекста LLM:** Вывод объемных списков задач, текстов системных промптов и таблиц снапшотов засорял контекстное окно чата, приводя к галлюцинациям и «залипанию» модели на старых правилах.
> * **Решение проблемы памяти (Topic Drift & Ephemeral Context):**
* В таблицу chat\_messages добавлен флаг is\_ephemeral \= 1 для временных сообщений.
* Введены двухфазные сессии (PROMPT\_PREVIEW → PROMPT\_FOLLOWUP) и детерминированный счетчик отвлечений idle\_turns.
* **Context Guard:** На 3-м шаге отвлечения оператора на сторонние темы система отвечает на вопрос и выводит интерактивное напоминание с кнопками \[Завершить настройку\] / \[Показать промпт\]. При idle\_turns \> 3 происходит автоочистка сессии вызовом db\_purge\_ephemeral\_messages().
* **Fast-Path:** Прямой перехват команд подтверждения («да», «подтверждаю», «сохранить») и выхода («нет, спасибо», «закончить настройку») без обращения к LLM (ответ за 0.05 сек).
* **Декларативный реестр действий (tool\_action\_registry):** Хранение шаблонов ответов, типов кнопок и признаков эфемерности в SQLite.
> * **Generative UI (Интерактивные карточки задач):** Инструмент db\_get\_tasks возвращает полезную нагрузку TASK\_INTERACTIVE\_CARD. Фронтенд рендерит живой виджет с табами (Все / В работе / В планах / Завершенные), чекбоксами и inline-созданием задач через REST API без вызова LLM.
## ---
**3\. Полная схема базы данных SQLite (data/scud\_orion\_ai.db)**
| Таблица | Назначение и бизнес-логика | Полная структура колонок |
| :---- | :---- | :---- |
| **scud\_logs** | Хранение логов проходов СКУД, расчетного времени и снапшотов. | id (PK), log\_date, fio, fio\_clean, department, position, time\_in, first\_activity, time\_out, time\_in\_building, is\_present, anomaly\_flag, snapshot\_time, snapshot\_id, created\_at |
| **zup\_staff** | Штатное расписание 1С:ЗУП на даты срезов. | id (PK), snapshot\_date, fio, fio\_clean, department, position, created\_at |
| **zup\_absences** | Официальные кадровые отклонения 1С (отпуска, больничные, командировки, декреты). | id (PK), absence\_date, fio, fio\_clean, absence\_type, created\_at |
| **anomalies\_history** | Журнал выявленных кадровых и аппаратных аномалий. | id (PK), anomaly\_date, fio, anomaly\_type, details, human\_status, created\_at |
| **ai\_knowledge\_base** | Глобальные правила арбитража и приоритеты компании для ИИ. | id (PK), rule\_text, added\_by, created\_at |
| **system\_prompts** | Версионные системные промпты агента (активный промпт по is\_active=1). | id (PK), name, prompt\_text, is\_active, updated\_at |
| **tool\_action\_registry** | Декларативный реестр шаблонов, кнопок и настроек эфемерности инструментов. | id (PK), tool\_name, category, bypass\_llm, success\_template, follow\_up\_question, action\_type, buttons\_json, is\_active, is\_ephemeral, updated\_at |
| **session\_states** | Активные состояния сессий, черновики и счетчик idle\_turns. | session\_id (PK), state\_type, pending\_data, updated\_at |
| **chat\_messages** | История диалогов с поддержкой разделения на постоянные и эфемерные сообщения. | id (PK), session\_id, role, content, is\_ephemeral, created\_at |
| **tasks** | Реестр задач пользователей с привязкой к user\_id. | id (PK), task\_id, module, title, priority, status, due\_date, user\_id, created\_at |
| **users** | Учетные записи операторов (хэши паролей bcrypt, права администратора). | id (PK), username, password\_hash, is\_admin, full\_name, created\_at |
| **department\_synonyms** | База знаний соответствия аббревиатур СКУД полным названиям отделов 1С. | id (PK), short\_name, full\_name, created\_at |
| **system\_reference** | Справочник команд и примеров промптов для операторов. | id (PK), category, title, example\_prompt, description |
## ---
**4\. Актуальная архитектура каталогов и назначение файлов**
`scud_ai/`
`├── config.py # Конфигурация: пути, расчет дат, URL Ollama, параметры MS SQL`
`├── exceptions.json # Декларативные фильтры исключений (ОВК, уборщики, подрядчики)`
`├── main_etl.py # Оркестратор ETL: экспорт СКУД/1С -> SQLite -> Excel/MD отчеты`
`├── project_code_snapshot.md # Полный исходный код всех файлов репозитория`
`├── core/`
`│ └── database.py # Драйвер SQLite (WAL), функции сохранения/загрузки срезов`
`├── services/`
`│ ├── scud_export.py # Прямой SQL-экспорт из MS SQL «Орион Pro»`
`│ ├── zup_extractor.py # Прямой SQL-экспорт из MS SQL 1С:ЗУП 3.1`
`│ ├── data_loader.py # Загрузка данных, нормализация ФИО, расчет первой активности`
`│ ├── data_validator.py # Проверка свежести и наличия файлов за Вчера и Сегодня`
`│ ├── excel_exporter.py # Генерация сводки и детального отчета в Excel (openpyxl)`
`│ ├── text_reporter.py # Построение итогового Markdown-отчета через Qwen 2.5`
`│ ├── ai_verifier.py # Модули ИИ-аудита, Fuzzy Matching и детекции сбоев СКУД`
`│ ├── feedback_loop.py # Интерактивный консольный модуль Human-in-the-Loop`
`│ ├── knowledge_base.py # Интерфейс работы с базой знаний правил`
`│ └── share_copier.py # Резервное скачивание файлов 1С с шары /mnt/scud_share`
`├── scripts/`
`│ ├── db_cli.py # CLI-утилита управления БД, снапшотами, дампами и сессиями`
`│ ├── fix_snapshots.py # Скрипт переиндексации и нормализации снапшотов`
`│ ├── init_tool_registry.py # Инициализация декларативного реестра инструментов`
`│ └── diagnostics/`
`│ ├── inspect_db.py # Диагностика структуры таблиц и количества записей`
`│ ├── inspect_ephemeral.py # Анализ распределения постоянных и эфемерных сообщений`
`│ ├── inspect_files.py # Аудит файлов и размеров на диске`
`│ └── show_tree.py # Вывод дерева каталогов`
`├── modules/web_api/`
`│ ├── main.py # Точка входа FastAPI приложения, роутеры и раздача статики`
`│ ├── routers/`
`│ │ ├── admin.py # Управление пользователями`
`│ │ ├── auth.py # JWT аутентификация и смена паролей`
`│ │ ├── chat.py # Эндпоинты диалогов (авторизованный и гостевой)`
`│ │ └── tasks.py # REST API управления задачами`
`│ ├── llm/`
`│ │ ├── agent.py # Координатор диалога, Tool Calls и Topic Drift Guard`
`│ │ ├── schemas.py # Описание JSON-схем инструментов для Ollama`
`│ │ ├── file_parser.py # Мультимодальный парсер файлов (PDF, PNG, XLSX, TXT)`
`│ │ ├── db_tools.py # Фасадные вызовы функций БД для агента`
`│ │ ├── core/`
`│ │ │ ├── calendar_utils.py # Формирование системного контекста дат и дней недели`
`│ │ │ ├── fast_path.py # Быстрый перехват команд подтверждения/отмены (0.05 с)`
`│ │ │ ├── ollama_client.py # HTTP-клиент взаимодействия с Ollama API`
`│ │ │ ├── prompt_merger.py # Парсинг и слияние правок системного промпта`
`│ │ │ └── tool_injector.py # Очистка сырых тегов и артефактов Tool Calls`
`│ │ └── db/`
`│ │ ├── connection.py # Подключение к scud_orion_ai.db`
`│ │ ├── db_chat.py # Сохранение истории и очистка эфемерных сообщений`
`│ │ ├── db_prompts.py # Системные промпты, стейты и реестр действий`
`│ │ ├── db_snapshots.py # Выборка и удаление снапшотов`
`│ │ └── db_tasks.py # CRUD операции с задачами пользователей`
`│ └── static/`
`│ ├── index.html # SPA разметка (модалки, чат, боковая панель задач)`
`│ ├── css/styles.css # Стили и мобильные оптимизации`
`│ └── js/`
`│ ├── app.js # Инициализация приложения`
`│ ├── auth.js # Логика входа, смены паролей и администрирования`
`│ ├── chat.js # Обработка сообщений, Drag&Drop, виджет задач и кнопки`
`│ └── tasks.js # Управление боковой панелью задач (Drawer)`
`└── docs/`
`└── development_roadmap.md # Стратегическая дорожная карта и архитектурный бэклог`
## ---
**5\. Справочник команд командной строки (CLI Reference)**
### **Главный конвейер контроллинга (main\_etl.py)**
> * python main\_etl.py — стандартный дневной запуск: экспорт свежих данных СКУД и 1С, сохранение снапшота в SQLite, генерация Excel и Markdown отчетов.
> * python main\_etl.py \--skip-export — построение отчетов по последнему имеющемуся снапшоту из SQLite без повторного запроса к MS SQL.
> * python main\_etl.py \--snapshot 20260805-001 — точный расчет отчетов по указанному составному ID снапшота.
> * python main\_etl.py \-d — запуск в режиме расширенной отладки (DEBUG).
### **CLI-утилита управления SQLite базой данных (scripts/db\_cli.py)**
> * python scripts/db\_cli.py stats — общая статистика количества записей по всем таблицам БД.
> * python scripts/db\_cli.py snapshots \[ДД.ММ.ГГГГ\] — просмотр реестра снапшотов с сортировкой по времени создания (самые свежие сверху).
> * python scripts/db\_cli.py scud \[ДД.ММ.ГГГГ\] \[--snapshot ID\] \[--export-xlsx NAME\] — просмотр логов СКУД и опциональный экспорт в Excel.
> * python scripts/db\_cli.py absences \[ДД.ММ.ГГГГ\] — список кадровых отклонений из 1С:ЗУП.
> * python scripts/db\_cli.py anomalies — история выявленных аномалий.
> * python scripts/db\_cli.py rules — правила базы знаний компании.
> * python scripts/db\_cli.py prompts — просмотр системных промптов из таблицы system\_prompts.
> * python scripts/db\_cli.py tools — вывод декларативного реестра действий инструментов и шаблонов кнопок.
> * python scripts/db\_cli.py sessions — просмотр активных сессионных стейтов и висящих черновиков.
> * python scripts/db\_cli.py snapshot del \[ID\] — удаление конкретного снапшота по ID.
> * python scripts/db\_cli.py snapshot del \--day \[ДД.ММ.ГГГГ\] — удаление всех снапшотов за выбранный день.
> * python scripts/db\_cli.py dump \[backup.xlsx\] — полный дамп всех таблиц базы данных в многостраничный Excel-файл.
## ---
**6\. Стратегическая дорожная карта развития (Roadmap & Backlog)**
> 1. **Масштабирование механизма эфемерного контекста на все инструменты (Rollout Roadmap):**
* **Снапшоты (snapshots):** Перевод вывода объемных таблиц срезов в эфемерный режим (is\_ephemeral \= 1), внедрение интерактивного подтверждения при удалении снапшотов по ID/дате через состояния SNAPSHOTS\_PREVIEW / SNAPSHOTS\_FOLLOWUP.
* **База знаний и аномалии (kb & anomalies):** Вывод справочников команд, правил арбитража и списков аномалий исключительно во временный слой для исключения «залипания» модели на устаревших правилах из истории сообщений.
* **Декларативная унификация в tool\_action\_registry:** Расширение схемы таблицы полями followup\_state\_type, auto\_purge\_on\_exit, guard\_question\_template для устранения хардкода имен функций в agent.py.
> 2. **Модуль анализа внутридневных перемещений («Контроль перекуров»):**
* Анализ интервалов между событиями OUT (выход) и IN (возврат) по журналу pLogData.
* Подсчет суммарного времени нахождения вне рабочего места в течение смены с отсечением технологической нормы (30–40 минут).
* Вывод аналитического блока и злостных нарушителей в отдельный лист Excel-отчета и итоговую Markdown-сводку.
> 3. **Интерактивный Task Planner & Roadmap Engine:**
* Специализированный инструмент для ведения динамических проектных планов и чек-листов с чекбоксами (\[ \] / \[x\]) прямо в диалоге с синхронизацией в таблицу tasks.
> 4. **Изолированная среда исполнения кода (Code Execution Sandbox):**
* Безопасный изолированный Docker/gVisor контейнер для динамического выполнения генерируемого моделью Python/Pandas кода на лету с возвратом stdout/stderr и графиков в контекст.
> 5. **Автоматизация расписания срезов и Telegram-алерты:**
* Автозапуск выгрузок в 12:00, 17:00 и 19:00 через systemd-таймеры с фиксацией snapshot\_time.
* Отправка мгновенных алертов о массовых сбоях турникетов (\>5%) и критических кадровых конфликтах дежурному системному администратору в Telegram.
-50
View File
@@ -1,50 +0,0 @@
# 🗺️ ДОРОЖНАЯ КАРТА И АРХИТЕКТУРНЫЙ РОАДМАП ПРОЕКТА SCUD ORION AI
## 📌 Зафиксированные вехи и концептуальные архитектурные решения
Схема веток Gitea для ближайшего развития проекта:
main (100% стабильный релиз)
│
├──► feature/tool-registry-db ──────┐ (Создание таблицы tool_action_registry в SQLite)
│ ▼
├───────────────────────────────► merge to main
│
├──► feature/interactive-buttons ───┐ (Подключение Да/Нет чипсов на фронтенде)
│ ▼
├───────────────────────────────► merge to main
│
└──► feature/ephemeral-context ─────┐ (Внедрение флагов is_ephemeral для очистки памяти LLM)
▼
merge to main
### 1. Архитектура скользящего контекста и отслеживания смены темы (Intent / Topic Drift Tracking)
* **Проблема:** При работе с длинными операциями (редактирование промптов, мастера табелей, снапшотов) оператор может отвлекаться на сторонние вопросы или уточнения. Жесткий сброс сессии уничтожает черновики, а вечное хранение засоряет память LLM.
* **Решение (Сферическое / Фундаментальное):**
1. **Семантическая оценка моделью:** Модель через системный контекст определяет, относится ли вопрос оператора к активному черновику/инструменту или разговор ушел в сторону (`topic_shift = true`).
2. **Детерминированный счетчик в бэкенде:** Бэкенд фиксирует шаги отвлечения (`idle_turns`). Пока $N < 3$, оператор может свободно общаться, не теряя висящий контекст.
3. **Вежливый перехват и выбор (Guard):** На 3-м шаге отвлечения система отвечает на текущий вопрос оператора и мягко напоминает о незавершенной транзакции кнопками `[Применить / Сохранить]` или `[Отменить и сбросить]`.
---
## 🚀 Будущие модули и запланированный функционал (Backlog)
### 2. Интерактивный модуль планирования и дорожных карт (Checklist & Task Planner Engine)
* **Концепция:** Специализированный инструмент для LLM-агента, позволяющий вести динамические проектные чек-листы и планы с интерактивными чекбоксами прямо в диалоге и базе данных.
* **Ключевые возможности:**
* Составление многоуровневых планов с чекбоксами (`[ ]` / `[x]`).
* Фиксация промежуточных комментариев и статусов выполнения по каждому пункту.
* Синхронизация задач плана с SQLite-таблицей `tasks`.
* Экспорт планов в Markdown/Excel и отображение на дашборде.
### 3. Изолированная среда выполнения и песочница кода (Code Execution Sandbox Engine)
* **Концепция:** Безопасный изолированный Docker-контейнер (или gVisor / Pyodide / nsjail окружение) для динамического выполнения кода, генерируемого моделью в процессе рассуждений и анализа данных.
* **Ключевые возможности:**
* Запуск сложных вычислений, агрегаций и статистического анализа данных СКУД на лету (Pandas / NumPy).
* Выполнение тестовых сценариев и валидация скриптов перед их сохранением/применением на проде.
* Полная изоляция от хост-системы: read-only доступ к копиям данных, ограничение памяти/CPU (cgroups), отсутствие доступа к чувствительным сетевым интерфейсам.
* Возврат результатов вычислений (stdout, stderr, артефакты, сгенерированные таблицы/графики) обратно в контекст модели.
+196
View File
@@ -0,0 +1,196 @@
# План реализации (ROADMAP)
## 1. Очистка от регулярок и костылей (`tool_injector.py`) `[ЗАВЕРШЕНО]`
- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 2].
- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`)[cite: 2].
---
## 2. Настройка контекста и инструкций сессии (`agent.py`) `[ЗАВЕРШЕНО]`
- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 2].
- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию[cite: 2]:
- **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 2].
- **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 2].
- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 2].
- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`[cite: 2].
- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов[cite: 2].
---
## 3. Очистка эфемерных сообщений при завершении (`fast_path.py` / `context_manager.py`) `[ЗАВЕРШЕНО]`
- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 2].
- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 2].
- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 2].
- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста[cite: 2].
- [x] Создать `context_manager.py` для строгого разграничения служебных tool-пар (`is_ephemeral=1`) и содержательного диалога (`is_ephemeral=0`), сохраняя беседы Topic Drift в истории[cite: 2].
---
## 4. Инлайн-редактор в окне диалога (`core.js`) `[ЗАВЕРШЕНО]`
- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 2].
- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 2].
- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`)[cite: 2].
- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика[cite: 2].
---
## 5. Тестирование и валидация системного промпта `[ЗАВЕРШЕНО]`
- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 2].
- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 2].
- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 2].
- [x] **Строгий вызов Базы Знаний:** внедрено правило 2.9 в системный промпт для пресечения текстовой имитации вызова `db_get_rules`[cite: 2].
---
## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) `[ЗАВЕРШЕНО]`
- [x] **Масштабирование UI задач:**
- Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`[cite: 2].
- Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все»[cite: 2].
- Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих)[cite: 2].
- [x] **Инлайн-редактирование карточки задачи:**
- Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности)[cite: 2].
- Отображение даты создания задачи[cite: 2].
- Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`[cite: 2].
- [x] **Детерминированный Fast-Path и двухфазное удаление:**
- Мгновенная смена статусов без задержек LLM[cite: 2].
- Карточка подтверждения удаления с автоочисткой контекста[cite: 2].
- [x] **Генерация отчетов задач в Markdown:**
- Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`[cite: 2].
- Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`)[cite: 2].
- [x] **Доменная консолидация задач:**
- Реализован консолидированный инструмент `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE", "EXPORT"], ...)`[cite: 2].
---
## 7. Распространение на модуль снапшотов (`snapshots`) `[ЗАВЕРШЕНО]`
- [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00 / 23:59:59[cite: 2].
- [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат[cite: 2].
- [x] **Интерактивный UI с чекбоксами и защитой срезов:**
- Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке[cite: 2].
- Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора)[cite: 2].
- Динамическая кнопка «Удалить выбранные (N)» в подвале карточки[cite: 2].
- [x] **Декларативное управление удалением срезов:**
- Добавлен параметр `confirmed` в схему `TOOLS_SCHEMA` для `db_delete_snapshots`, исключающий зацикливание подтверждений в LLM.
- Двухфазное подтверждение удаления (одиночное и пакетное) с корректным счётчиком количества удаляемых элементов[cite: 2].
- Корректное отображение времени создания срезов в интерфейсе (устранение смещения UTC относительно локального времени сервера).
---
## 8. Глубокий архитектурный рефакторинг ядра и сервисов `[ЗАВЕРШЕНО]`
- [x] **Выделение общего слоя ядра (`core/`):**
- Изолированный пул соединений SQLite (`core/connection.py`) с поддержкой WAL и таймаутов[cite: 2].
- DDL-схемы таблиц и индексов (`core/schema.py`)[cite: 2].
- Репозитории СКУД и 1С (`core/repositories/scud_repo.py`, `core/repositories/zup_repo.py`)[cite: 2].
- Фасад обратной совместимости (`core/database.py`)[cite: 2].
- [x] **Декомпозиция предметных доменов (`services/`):**
- Сервис задач (`services/tasks/`), системного промпта (`services/prompts/`), срезов СКУД (`services/snapshots/`), базы знаний (`services/knowledge/`)[cite: 2].
- [x] **Рефакторинг ETL-конвейера (`services/scud_etl/`):**
- Оркестратор `pipeline.py`, детектор аномалий `anomaly_detector.py`, слияние `merger.py`[cite: 2].
- [x] **Модульный ИИ-оркестратор (`modules/ai_engine/`):**
- Динамический строитель системного контекста (`context_builder.py`)[cite: 2].
- Изолированные обработчики инструментов в `handlers/`[cite: 2].
---
## 9. Реляционный реестр исключений, схлопывание мульти-пропусков и кадровый аудит `[ЗАВЕРШЕНО]`
- [x] **Реестр исключений в SQLite (`exceptions_registry`):**
- Хранение категорий `departments`, `positions`, `fio`, `position_keywords`, `include_fio` в БД[cite: 2].
- Доменный сервис `exceptions_repo.py` и полная поддержка в CLI `scripts/db_cli.py exceptions`[cite: 2].
- [x] **Агрегация мульти-пропусков физлиц в СКУД:**
- Функция `aggregate_scud_by_person` в `merger.py`: объединение событий всех пропусков одного человека (ранний вход, поздний выход, присутствие)[cite: 2].
- Автоматическая фиксация аномалий дублирования пропусков (`DUPLICATE_SCUD_CARD`)[cite: 2].
- [x] **Умный выбор ставки совместителей 1С:ЗУП:**
- Функция `select_best_zup_position` в `merger.py`: привязка ставки 1С по подразделению физического нахождения в СКУД[cite: 2].
- [x] **Отказ от искусственного автозакрытия смен:**
- Полное отключение механизма дорисовывания 8.5 часов (`calculate_autoclose_time`) по согласованию с отделом кадров.
- Сохранение честного статуса «Нет выхода» для прозрачности кадрового аудита и выявления нарушений.
- [x] **Кэш сопоставлений личностей (`person_identity_mapping`):**
- Таблица в SQLite и команды `db_cli.py mapping [list|add|del]`[cite: 2].
- Теневой режим ИИ-подсказок по нечетким ФИО в `text_reporter.py`[cite: 2].
- [x] **Каскадный fallback кадровых отсутствий:**
- `MS SQL ЗУП` $\rightarrow$ резервный парсинг `Отсутствия_*.xlsx` $\rightarrow$ `static_reason_workers.csv`[cite: 2].
---
## 10. Разделение генераторов и почасовые срезы `[ЗАВЕРШЕНО]`
- [x] Разделение логики генерации на `svodka_generator.py` и `otchet_generator.py`[cite: 2].
- [x] Перевод времени суточного среза `Y` на `23:59:59`[cite: 2].
- [x] Интеллектуальный поиск срезов `services/snapshots/finder.py` (Snap-to-Grid ±20 мин)[cite: 2].
- [x] Флаг `--time` и интерактивный help в `main_etl.py`[cite: 2].
- [x] Флаг `--export-only` для почасового крона[cite: 2].
- [x] Политика ночной ротации промежуточных срезов `services/snapshots/retention.py`[cite: 2].
- [x] Исправление целочисленного инкремента `snapshot_id`[cite: 2].
---
## 11. Стабилизация генератора отчетов и верстки `[ЗАВЕРШЕНО]`
- [x] **Миграция на `XlsxWriter`:** полный перевод экспорта книг (`scud_export.py`, `excel_exporter.py`) на чистый генератор, устранивший ошибки OpenXML и окна восстановления при открытии[cite: 2].
- [x] **Интерактивные группировки в Сводке:** восстановление сворачивания категорий по умолчанию (`level=1`, `collapsed=True`) и кнопок управления уровнями `[+]`/`[-]` (`outline_settings(symbols_below=False)`)[cite: 2].
- [x] **Компактная экранная верстка Детального отчета:** центрирование столбца «Подразделение», расширение столбца «ФИО» (+10%, ширина 33), оптимизация ширины колонок «Первая активность» (11) и «Отклонение от нормы» (11) для отображения без горизонтальной прокрутки[cite: 2].
- [x] **Защита от блокировок занятых файлов (Fallback Timestamp):** механизм перехвата `FileCreateError`/`OSError` в `safe_close_workbook` с сохранением копии при открытом в Excel файле[cite: 2].
- [x] **Безопасная синхронизация 1С с сетевой шары:** переход на `shutil.copyfile` в `share_copier.py` для устранения сбоев прав доступа (`Operation not permitted`) на SMB/CIFS-ресурсах[cite: 2].
---
## 12. UI/UX веб-интерфейса и управление реестрами `[ЗАВЕРШЕНО]`
- [x] **Паттерн Gemini-скролла:** реализация функции `scrollToUserMessageTop` в `core.js`, позиционирующей свежий вопрос пользователя строго по верхней кромке видимой области[cite: 2].
- [x] **Полное скрытие истории:** предыдущие объемные ответы модели гарантированно вытесняются за верхний край экрана[cite: 2].
- [x] **Стабилизация панели ввода:** настройка `padding-bottom: 80vh` для `#chat-messages-container` в `index.html`, обеспечивающая необходимый запас высоты прокрутки без сдвига строки ввода текста[cite: 2].
- [x] **Модальные формы реестров с автокомплитом 1С:**
- Полный отказ от браузерного `prompt()` при работе со списками исключений.
- Единое Tailwind-окно для добавления исключений с живым поиском сотрудников по базе 1С:ЗУП (`staff-autocomplete`) и полем для комментария/основания.
---
## 13. Архитектура обработки событий турникетов и двухконтурный учет `[ЗАВЕРШЕНО]`
- [x] **Опора на физический факт прохода (Event 32):**
- Устранение потери событий из-за рассинхронизации меток Event 28 (разрешение) и Event 32 (проход).
- Переход на плоский SQL-запрос выборки первого входа (`Mode = 1`) и последнего выхода (`Mode = 2`).
- [x] **Двухконтурная модель турникетов (Левый/Правый PERCo):**
- Разрешение использования обоих турникетов (`DoorIndex IN (1, 2)`) для сотрудников дворовых служб (отделы `ЭТО`, `ЛЦ` и др.).
- Защита основного пула офисных сотрудников от транзитных отметок на правом турникете (`DoorIndex = 1`).
- Реестр «Пр. турникет» (`turnstile_fio`, `turnstile_departments`) в базе данных и веб-панели.
- Корректное сопоставление составных ФИО (`Name + FirstName + MidName`) в запросе к `pList`.
- [x] **Таблица сырых событий (`scud_events_raw`):**
- Создание DDL-схемы таблицы и логирование всех физических проходов дня параллельно со снапшотами.
---
## 14. Внутридневной контроль и генерация отчетов из веб-интерфейса `[В РАБОТЕ]`
- [ ] **Моментальное создание срезов из UI:**
- [ ] Кнопка **«Создать моментальный срез»** в шапке хаба «Срезы».
- [ ] Вызов `scud_export.py` с фиксацией состояния на текущую минуту и добавлением среза в список без перезагрузки страницы.
- [ ] **Контекстные кнопки генерации в строках срезов:**
- [ ] Размещение кнопок быстрых действий напротив каждой карточки среза в сайдбаре:
- Для промежуточных срезов (`HH:00`): кнопка **«Сводка»** $\rightarrow$ запуск `svodka_generator.py` на момент среза.
- Для итоговых срезов (`_FINAL` / `Y`): кнопка **«Отчет»** $\rightarrow$ запуск `otchet_generator.py` со сверкой 1С:ЗУП за сутки.
- [ ] Фоновая сборка документа с отображением индикатора и автоматической выдачей ссылки на скачивание файла (`/api/v1/files/download/...`).
- [ ] **Выделенный экран оперативного мониторинга («Текущая сводка»):**
- [ ] Отдельная страница/вкладка оперативного контроля присутствия в реальном времени:
- Метрики: *Всего по штату*, *В здании прямо сейчас*, *На удаленке*, *В командировке*, *Отсутствуют без причины*.
- Быстрый поиск и фильтрация по подразделениям.
- Подсветка аномалий внутри дня (вход без выхода более 10 часов, активность без прохода турникета).
---
## 15. Интеллектуальный кадровый арбитраж ДО генерации отчетов `[В ПЛАНАХ]`
- [ ] **Двухконтурный вызов ИИ:** перенос арбитража не сопоставившихся персон на этап [2.5] ДО сохранения Excel-файлов `сводка.xlsx` и `отчет.xlsx`[cite: 2].
- [ ] **Якорный табельный номер (TabNo Matching):**
- [ ] Извлечение `TabNo` из MS SQL СКУД Орион и 1С:ЗУП[cite: 2].
- [ ] Добавление колонок `scud_tab_no` и `zup_tab_no` в SQLite[cite: 2].
- [ ] Защита от смены фамилий и опечаток через инвариант табельного номера[cite: 2].
- [ ] **4-уровневая система предохранителей (Guardrails):**
- Уровень 1: Точный матч ФИО (100%)[cite: 2].
- Уровень 2: Матч по табельному номеру при расхождении ФИО[cite: 2].
- Уровень 3: Валидация кэша `person_identity_mapping` с проверкой актуальности статуса в 1С[cite: 2].
- Уровень 4: ИИ-арбитраж с записью верифицированной связки в кэш[cite: 2].
---
## 16. Изолированная песочница кода (Code Execution Sandbox Engine) `[В ПЛАНАХ]`
- [ ] **Docker/gVisor контур:**
- Создание изолированного контейнера без доступа к внешней сети (`network: none`) с ограниченными лимитами по памяти и CPU (cgroups)[cite: 2].
- Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only[cite: 2].
- [ ] **Динамические Python/Pandas вычисления:**
- Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 2].
- Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 2].
+4655
View File
File diff suppressed because it is too large Load Diff
+7 -4
View File
@@ -7,10 +7,9 @@
"Уборщик служебных помещений" "Уборщик служебных помещений"
], ],
"fio": [ "fio": [
"Таткало Валерий Валерьевич", "Таткало Валерий Валерьевич",
"Петренюк Андрей Германович", "Петренюк Андрей Германович",
"Чуркина Елена Геннадьевна", "Михалев Сергей Геннадьевич"
"Михалев Сергей Геннадьевич"
], ],
"position_keywords": [ "position_keywords": [
"уборщик", "уборщик",
@@ -18,5 +17,9 @@
"дворник", "дворник",
"гардероб", "гардероб",
"рабочий по обслуживанию" "рабочий по обслуживанию"
],
"include_fio": [
"Тарасенко Александр Александрович",
"Журиков Михаил Николаевич"
] ]
} }
+154 -529
View File
@@ -1,571 +1,196 @@
"""
===============================================================================
FILE: main_etl.py
PROJECT: SCUD Orion AI (Unified Architecture)
ROLE: Главная точка входа ETL-конвейера СКУД ⟷ 1С:ЗУП.
===============================================================================
"""
import os import os
import sys import sys
import json
import argparse import argparse
import logging
import pandas as pd import pandas as pd
from datetime import datetime, timedelta from datetime import datetime, timedelta
sys.path.append(os.path.dirname(os.path.abspath(__file__))) from services.scud_etl.pipeline import load_best_snapshot_for_date, load_1c_files_for_date
from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics
from services.scud_etl.anomaly_detector import detect_registry_anomalies
from services.scud_etl.svodka_generator import generate_svodka_service
from services.scud_etl.otchet_generator import generate_otchet_service
from services.text_reporter import generate_markdown_report
from services.snapshots.retention import cleanup_old_intermediate_snapshots
from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR, DATA_DIR, normalize_fio
from services.scud_export import run_export from services.scud_export import run_export
from services.share_copier import copy_1c_files_from_share from services.share_copier import copy_1c_files_from_share
from services.data_validator import check_file_freshness from services.excel_exporter import export_raw_scud
from services.data_loader import load_1c_data_smart, load_scud_data
from services.excel_exporter import generate_summary_excel, generate_detailed_excel logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s")
from services.ai_verifier import ai_verify_scud_against_staff, analyze_scud_mass_failure_ai
from services.text_reporter import generate_markdown_report
from services.feedback_loop import review_ai_decisions
from services.knowledge_base import load_knowledge_base
from core.database import (
init_db,
save_scud_to_db,
save_staff_to_db,
save_absences_to_db,
save_anomalies_to_db,
load_scud_from_db_by_snapshot,
load_staff_from_db,
load_absences_from_db,
get_latest_snapshot_time,
has_scud_logs_for_date
)
def load_exceptions_config(): def print_help():
"""Загружает файл exceptions.json из корня проекта.""" print("""
root_dir = os.path.dirname(os.path.abspath(__file__)) ===============================================================================
json_path = os.path.join(root_dir, "exceptions.json") 🛠️ SCUD ORION AI — СИСТЕМА КОНТРОЛЛИНГА И СВОДНЫХ ОТЧЕТОВ
if not os.path.exists(json_path): ===============================================================================
return {} Использование:
try: python main_etl.py [ОПЦИИ]
with open(json_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"[⚠️] Ошибка чтения exceptions.json: {e}")
return {}
Доступные аргументы:
-h, --help, help Показать эту справку и выйти
--date ДД.ММ.ГГГГ Дата расчета (по умолчанию: текущий рабочий день)
--time ЧЧ:ММ Время среза для сводки (например: 14:30)
Ищет ближайший срез (±20 мин) или запрашивает On-Demand экспорт
--snapshot ID Точный ID снапшота для расчета (например: 20260827-002)
--skip-export Пропустить выгрузку СКУД из MS SQL (работать только с SQLite)
--export-only ТОЛЬКО сделать экспорт/снапшот СКУД в БД без построения отчетов
-d, --debug Включить режим расширенной отладки
def apply_exceptions_from_json(df, exceptions_cfg): Примеры использования:
""" python main_etl.py
Быстрая и эффективная разметка флага is_excluded=True на основе exceptions.json. 👉 Полный суточный цикл: экспорт -> отчет за вчера -> сводка за сегодня -> ИИ.
Выполняется мгновенно без цикличных HTTP-запросов к ИИ.
"""
if df is None or df.empty or not exceptions_cfg:
if df is not None:
df['is_excluded'] = False
return df
deps = [d.strip().lower() for d in exceptions_cfg.get("departments", []) if d] python main_etl.py --export-only
exact_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p] 👉 Почасовой тихий срез в БД (для cron) без генерации отчетов.
pos_kw = [k.strip().lower() for k in exceptions_cfg.get("position_keywords", []) if k]
exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f]
df['is_excluded'] = False python main_etl.py --date 27.08.2026 --time 12:15 --skip-export
👉 Построить сводку за 27.08 на 12:15 без запроса к внешнему MS SQL.
for idx, row in df.iterrows(): python main_etl.py --snapshot 20260827-001 --skip-export
fio_clean = row.get('fio_clean', '') 👉 Расчет отчетов строго по выбранному снапшоту из SQLite.
dep_1c = str(row.get('Подразделение', '')).lower() ===============================================================================
dep_scud = str(row.get('department_scud', row.get('department', ''))).lower() """)
pos = str(row.get('Должность', '')).lower()
is_fio_exc = fio_clean in exc_fios
is_pos_exc = (pos in exact_pos) or any(k in pos for k in pos_kw if k) if pos else False
# Быстрая проверка отделов по подстрокам
is_dep_exc = False
if deps:
is_dep_exc = any(d in dep_1c or d in dep_scud for d in deps)
if is_fio_exc or is_dep_exc or is_pos_exc:
df.at[idx, 'is_excluded'] = True
return df
def filter_report_dataframe(merged_df):
"""
Исключает сотрудников из списка исключений и сотрудников без пропуска из детального отчета,
ЕСЛИ у них нет официального документа отсутствия из 1С:ЗУП.
"""
if merged_df is None or merged_df.empty:
return merged_df
has_1c_reason = (
merged_df['Вид_отсутствия'].notna() &
(merged_df['Вид_отсутствия'].astype(str).str.strip() != '') &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
)
is_not_excluded = merged_df.get('is_excluded', False) == False
is_not_no_pass = merged_df.get('no_scud_pass', False) == False
filtered_df = merged_df[(is_not_excluded & is_not_no_pass) | has_1c_reason].copy()
return filtered_df
def load_static_reason_workers():
"""Загружает реестр удалёнщиков и статических причин из CSV."""
static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
if not os.path.exists(static_path):
return {}
try:
df_static = pd.read_csv(static_path, encoding='utf-8')
if 'fio' in df_static.columns and 'reason' in df_static.columns:
df_static['fio_clean'] = df_static['fio'].apply(normalize_fio)
return dict(zip(df_static['fio_clean'], df_static['reason']))
except Exception as e:
print(f"[⚠️] Ошибка чтения static_reason_workers.csv: {e}")
return {}
def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules, scud_fios_set=None):
"""
Автоматически выявляет истинные аномалии СКУД ⟷ 1С.
"""
anomalies = []
kb_rules_text = " ".join(kb_rules).lower() if kb_rules else ""
ALLOWED_WORK_TRIP_KEYWORDS = ['командировк', 'разъездн', 'поездк']
for idx, row in merged_df.iterrows():
fio = row.get('Сотрудник', row.get('fio_clean', ''))
fio_clean = row.get('fio_clean', '')
is_present = row.get('Пришел', False)
is_exc = row.get('is_excluded', False)
reason_1c = str(row.get('Вид_отсутствия', '')).strip()
has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != '' and not reason_1c.startswith('Исключение')
anom_flag = row.get('anomaly_flag', 'NONE')
is_fio_whitelisted_in_kb = fio_clean.lower() in kb_rules_text
if is_present and has_1c_reason:
reason_lower = reason_1c.lower()
is_allowed_trip = any(kw in reason_lower for kw in ALLOWED_WORK_TRIP_KEYWORDS)
if not is_allowed_trip and not is_fio_whitelisted_in_kb:
anomalies.append({
"type": "ФИЗИЧЕСКОЕ ПРИСУТСТВИЕ ПРИ ОФИЦИАЛЬНОМ ОТСУТСТВИИ",
"fio": fio,
"details": f"Сотрудник пришел по СКУД, но в 1С оформлен документ: '{reason_1c}'"
})
if is_exc and not has_1c_reason:
continue
if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY':
first_act = row.get('Первая_активность', '—')
anomalies.append({
"type": "АНОМАЛИЯ СКУД: ПЕРЕМЕЩЕНИЕ БЕЗ ВХОДА",
"fio": fio,
"details": f"Отсутствует регистрация входа на КПП при зафиксированной первой активности в {first_act}"
})
if scud_fios_set is not None:
if fio_clean not in scud_fios_set and not has_1c_reason:
anomalies.append({
"type": "АНОМАЛИЯ УЧЕТА: СОТРУДНИК ОТСУТСТВУЕТ В СКУД ОРИОН PRO",
"fio": fio,
"details": f"Сотрудник числится в Штатном расписании 1С ({row.get('Подразделение', '—')}), но полностью отсутствует в базе СКУД Орион Pro (профиль не создан или карта не выдана)"
})
return anomalies
def aggregate_scud_by_employee(df, debug=False):
"""Агрегирует проходы СКУД по уникальным сотрудникам."""
if df is None or df.empty or 'fio_clean' not in df.columns:
return df
aggregated = []
for fio_clean, group in df.groupby('fio_clean', sort=False):
is_present = group['Пришел'].any() if 'Пришел' in group.columns else False
if is_present and 'Пришел' in group.columns:
present_rows = group[group['Пришел'] == True]
best_row = present_rows.iloc[0].to_dict() if not present_rows.empty else group.iloc[0].to_dict()
else:
best_row = group.iloc[0].to_dict()
best_row['Пришел'] = is_present
aggregated.append(best_row)
return pd.DataFrame(aggregated)
def main(): def main():
help_text = """ if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help", "help"):
Система автоматизированного контроллинга СКУД ⟷ 1С:ЗУП (scud_orion_ai_v2) print_help()
sys.exit(0)
ПРИМЕРЫ ЗАПУСКА:
python main.py -- Обычный дневной запуск
python main.py --skip-export -- Расчет отчета по ПОСЛЕДНЕМУ имеющемуся снапшоту из SQLite
python main.py --snapshot 20260805-001 -- Расчет отчета строго по ID снапшота
python main.py -d -- Запуск в режиме расширенной отладки (DEBUG)
"""
parser = argparse.ArgumentParser(
description=help_text,
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument('-d', '--debug', action='store_true', help="Запуск в режиме отладки с выводом подробных логов")
parser.add_argument('--skip-export', action='store_true', help="Пропустить выгрузку СКУД из MS SQL и построить отчет по последнему снапшоту")
parser.add_argument('--snapshot', type=str, default=None, help="Составной ID снапшота или время создания")
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("-h", "--help", action="store_true")
parser.add_argument("-d", "--debug", action="store_true")
parser.add_argument("--skip-export", action="store_true")
parser.add_argument("--export-only", action="store_true")
parser.add_argument("--date", type=str, default=None)
parser.add_argument("--time", type=str, default=None)
parser.add_argument("--snapshot", type=str, default=None)
args = parser.parse_args() args = parser.parse_args()
DEBUG = args.debug if args.help:
print_help()
sys.exit(0)
print("=" * 60) print("=" * 60)
print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if DEBUG else ''}") print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG]' if args.debug else ''}")
print("=" * 60) print("=" * 60)
init_db() # Автоматическая ротация архивных почасовых срезов
kb_data = load_knowledge_base()
kb_rules = kb_data.get("rules", [])
exceptions_cfg = load_exceptions_config()
if args.snapshot:
snapshot_param = args.snapshot
print(f"[📸] РЕЖИМ СНАПШОТА: Расчет отчета строго по срезу '{snapshot_param}'")
elif args.skip_export:
snapshot_param = get_latest_snapshot_time()
print(f"[📸] РЕЖИМ --skip-export: Используем последний снапшот из SQLite ('{snapshot_param}')")
else:
snapshot_param = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[📸] СФОРМИРОВАН НОВЫЙ СНАПШОТ: '{snapshot_param}'")
if not args.skip_export and not args.snapshot: if not args.skip_export and not args.snapshot:
try: deleted_count = cleanup_old_intermediate_snapshots(days_to_keep_all=2)
run_export(save_xlsx=True, debug=DEBUG) if deleted_count > 0:
except Exception as e: print(f"[🧹] Ротация БД: очищено {deleted_count} строк промежуточных архивных срезов.")
print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Переходим к записям в SQLite.")
else:
print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...")
# ⚡️ ПРОВЕРКА ШАРЫ ТОЛЬКО ДЛЯ ОБЫЧНОГО ДНЕВНОГО ЗАПУСКА now = datetime.now()
if args.date:
today_str = args.date.replace('_', '.')
dt_target = datetime.strptime(today_str, "%d.%m.%Y")
days_back = 3 if dt_target.weekday() == 0 else 1
yesterday_str = (dt_target - timedelta(days=days_back)).strftime("%d.%m.%Y")
else:
today_str = now.strftime("%d.%m.%Y")
if now.weekday() == 0:
yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y")
else:
yesterday_str = (now - timedelta(days=1)).strftime("%d.%m.%Y")
# [Этап 0] Выгрузка свежих данных СКУД
if not args.skip_export and not args.snapshot:
print(f"\n[0/5] Экспорт данных СКУД за {today_str} и {yesterday_str}...")
run_export(input_date=args.date, debug=args.debug, save_xlsx=True)
else:
print("\n[0/5] Пропуск прямого экспорта СКУД из MS SQL (--skip-export)...")
# Если запрошен режим тихого почасового среза — выходим без тяжелых генераций
if args.export_only:
print(f"\n[✓] Режим --export-only: срез зафиксирован в SQLite. Генерация отчетов пропущена.")
sys.exit(0)
# [Этап 0.5] Синхронизация файлов с шары 1С
if not args.snapshot and not args.skip_export: if not args.snapshot and not args.skip_export:
print(f"\n[0.5/5] Проверка и копирование файлов 1С с шары...")
copy_1c_files_from_share() copy_1c_files_from_share()
print("[1/5] Проверка актуальности и свежести входных данных...")
is_valid, warnings, errors, has_today_1c = check_file_freshness()
if warnings:
print("\n--- ⚠️ ПРЕДУПРЕЖДЕНИЯ ОБ АКТУАЛЬНОСТИ ---")
for w in warnings:
print(f" • {w}")
print("-" * 45)
if not is_valid:
print("\n" + "!" * 60)
print("🛑 ОСТАНОВКА ВЫПОЛНЕНИЯ: Отсутствуют критически важные файлы за вчерашний день!")
for e in errors:
print(f" {e}")
print("!" * 60)
sys.exit(1)
print("[✓] Проверка доступности данных успешно пройдена!\n")
else: else:
print("[1/5] Пропуск проверки сетевой шары (все данные читаются из SQLite)...") print("\n[0.5/5] Пропуск синхронизации с шары (чтение локальных данных)...")
has_today_1c = True
print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...") # [Этап 1-2] Загрузка данных
print(f"\n[1-2/5] Загрузка срезов: Сегодня = {today_str}, Накануне = {yesterday_str}...")
# 🎯 ВЫЧИСЛЕНИЕ ДАТ СНАПШОТА И ДАТ НАКАНУНЕ df_scud_yesterday = load_best_snapshot_for_date(yesterday_str, prefer_final_y=True)
if args.snapshot or args.skip_export: df_scud_today = load_best_snapshot_for_date(today_str, prefer_final_y=False)
raw_scud_today_df = load_scud_from_db_by_snapshot(None, snapshot_param=snapshot_param)
if not raw_scud_today_df.empty and 'log_date' in raw_scud_today_df.columns:
target_date_str = str(raw_scud_today_df['log_date'].iloc[0])
else:
target_date_str = DATE_TODAY
dt_target = datetime.strptime(target_date_str, "%d.%m.%Y") df_staff_yesterday, df_abs_yesterday = load_1c_files_for_date(yesterday_str)
if dt_target.weekday() == 0: # Понедельник -> Пятница df_staff_today, df_abs_today = load_1c_files_for_date(today_str)
dt_yesterday = dt_target - timedelta(days=3)
else:
dt_yesterday = dt_target - timedelta(days=1)
yesterday_date_str = dt_yesterday.strftime("%d.%m.%Y")
print(f"[📸] СНАПШОТ ОПРЕДЕЛЕН: Целевая дата = {target_date_str}, Накануне = {yesterday_date_str}") if df_scud_today is not None and not df_scud_today.empty:
export_raw_scud(df_scud_today, filename=f"СКУД_Сырые_данные_{today_str}.xlsx")
raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None) # [Этап 3] Детальный отчет за вчера через otchet_generator
print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_str})...")
res_otchet = generate_otchet_service(target_date=yesterday_str)
if res_otchet.get("status") == "success":
print(f"[✓] {res_otchet.get('message')}: {res_otchet.get('filepath')}")
# Гарантированная загрузка кадров 1С за обе даты (БД + локальный фолбэк) # [Этап 4] Сводка за сегодня через svodka_generator
df_staff_today, df_absent_today = load_1c_data_smart(target_date_str, use_db=True) print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str} {args.time or ''}...")
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(yesterday_date_str, use_db=True) res_svodka = generate_svodka_service(
else: target_date=today_str,
target_date_str = DATE_TODAY target_time=args.time,
yesterday_date_str = DATE_YESTERDAY snapshot_id=args.snapshot
raw_scud_today_df = load_scud_from_db_by_snapshot(target_date_str, snapshot_param=snapshot_param)
raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None)
df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY, use_db=False)
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY, use_db=False)
if df_staff_yesterday is not None:
save_staff_to_db(df_staff_yesterday, yesterday_date_str)
if df_absent_yesterday is not None:
save_absences_to_db(df_absent_yesterday, yesterday_date_str)
if df_staff_today is not None:
save_staff_to_db(df_staff_today, target_date_str)
if df_absent_today is not None:
save_absences_to_db(df_absent_today, target_date_str)
staff_fios_yesterday_clean = df_staff_yesterday['fio_clean'].dropna().tolist() if df_staff_yesterday is not None else []
static_reasons_dict = load_static_reason_workers()
# ============================================================
# 🎯 ЧАСТЬ 1: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (ДЕНЬ НАКАНУНЕ)
# ============================================================
print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_date_str})...")
if not raw_scud_yesterday_df.empty and 'Пришел' not in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['Пришел'] = raw_scud_yesterday_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_yesterday_df.columns else False
scud_yesterday_unrecognized = raw_scud_yesterday_df[~raw_scud_yesterday_df['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist() if not raw_scud_yesterday_df.empty else []
fio_mapping_scud_y = ai_verify_scud_against_staff(scud_yesterday_unrecognized, staff_fios_yesterday_clean)
if fio_mapping_scud_y:
raw_scud_yesterday_df['fio_clean'] = raw_scud_yesterday_df['fio_clean'].apply(
lambda x: fio_mapping_scud_y[x]['staff_fio'] if x in fio_mapping_scud_y else x
)
if df_absent_yesterday is not None and not df_absent_yesterday.empty:
absent_unrecognized_yesterday = df_absent_yesterday[~df_absent_yesterday['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist()
if absent_unrecognized_yesterday:
fio_mapping_absent_y = ai_verify_scud_against_staff(absent_unrecognized_yesterday, staff_fios_yesterday_clean)
if fio_mapping_absent_y:
df_absent_yesterday['fio_clean'] = df_absent_yesterday['fio_clean'].apply(
lambda x: fio_mapping_absent_y[x]['staff_fio'] if x in fio_mapping_absent_y else x
)
raw_scud_yesterday_df = aggregate_scud_by_employee(raw_scud_yesterday_df, debug=DEBUG)
# ⭐️ ФОРМИРУЕМ МЕРДЖ ИСКЛЮЧИТЕЛЬНО НА БАЗЕ ШТАТА ЗА ПРОШЛЫЙ ДЕНЬ
merged_yesterday = df_staff_yesterday.copy() if (df_staff_yesterday is not None and not df_staff_yesterday.empty) else (df_staff_today.copy() if df_staff_today is not None else pd.DataFrame())
if not merged_yesterday.empty:
if not raw_scud_yesterday_df.empty:
if 'Подразделение' in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['Подразделение']
elif 'department' in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['department']
else:
raw_scud_yesterday_df['department_scud'] = ''
merged_yesterday = merged_yesterday.merge(
raw_scud_yesterday_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']],
on='fio_clean', how='left'
)
if 'Вид_отсутствия' in merged_yesterday.columns:
merged_yesterday = merged_yesterday.drop(columns=['Вид_отсутствия'])
# Присоединяем отсутствия СТРОГО за дату yesterday_date_str
if df_absent_yesterday is not None and not df_absent_yesterday.empty:
merged_yesterday = merged_yesterday.merge(
df_absent_yesterday[['fio_clean', 'Вид_отсутствия']],
on='fio_clean',
how='left'
)
if 'Пришел' not in merged_yesterday.columns:
merged_yesterday['Пришел'] = False
else:
merged_yesterday['Пришел'] = merged_yesterday['Пришел'].fillna(False)
if 'Сотрудник' not in merged_yesterday.columns:
merged_yesterday['Сотрудник'] = merged_yesterday.get('ФИО', merged_yesterday['fio_clean'])
if static_reasons_dict:
for fio_clean, reason_val in static_reasons_dict.items():
mask_yesterday = (
(merged_yesterday['Пришел'] == False) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday['fio_clean'] == fio_clean)
)
merged_yesterday.loc[mask_yesterday, 'Вид_отсутствия'] = reason_val
merged_yesterday = apply_exceptions_from_json(merged_yesterday, exceptions_cfg)
mask_exc_yesterday = (
(merged_yesterday['Пришел'] == False) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday.get('is_excluded', False) == True)
) )
merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' if res_svodka.get("status") == "success":
print(f"[✓] {res_svodka.get('message')}: {res_svodka.get('filepath')}")
if res_svodka.get("note"):
print(f" ℹ️ {res_svodka.get('note')}")
scud_fios_yesterday_set = set(raw_scud_yesterday_df['fio_clean'].dropna().tolist()) if not raw_scud_yesterday_df.empty else set() # [Этап 5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)
merged_yesterday['no_scud_pass'] = ( print(f"\n[5/5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)...")
(~merged_yesterday['fio_clean'].isin(scud_fios_yesterday_set)) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) & df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today)
(merged_yesterday.get('is_excluded', False) == False) anomalies_today = detect_registry_anomalies(df_merged_today, df_raw_scud=df_scud_today)
absent_explained = df_merged_today[
(df_merged_today['Пришел'] == False) &
(df_merged_today['Вид_отсутствия'].notna()) &
(~df_merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
]
absent_unexplained = df_merged_today[
(df_merged_today['Пришел'] == False) &
(df_merged_today['Вид_отсутствия'].isna() | (df_merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(df_merged_today.get('is_excluded', False) == False)
]
summary_md = generate_markdown_report(
merged_df=df_merged_today[df_merged_today.get('is_excluded', False) == False],
absent_explained=absent_explained,
absent_unexplained=absent_unexplained,
scud_present_but_absent_in_1c=pd.DataFrame(),
anomalies_list=anomalies_today,
raw_scud_df=df_scud_today,
raw_staff_df=df_staff_today,
raw_absent_df=df_abs_today,
date_str=today_str
) )
os.makedirs("output", exist_ok=True)
md_file_path = f"output/Сводка_контроллинга_{today_str}.md"
with open(md_file_path, "w", encoding="utf-8") as f:
f.write(summary_md)
print(f"[✓] Текстовый отчет сохранен в: {md_file_path}")
anomalies_yesterday_list = detect_all_anomalies(merged_yesterday, static_reasons_dict, kb_rules, scud_fios_set=scud_fios_yesterday_set) print("\n" + "=" * 60)
save_anomalies_to_db(anomalies_yesterday_list, yesterday_date_str) print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ-АУДИТОРА:")
print("=" * 60)
filtered_yesterday = filter_report_dataframe(merged_yesterday) print(summary_md)
generate_detailed_excel(merged_df=filtered_yesterday, date_str=yesterday_date_str)
yesterday_dt_obj = datetime.strptime(yesterday_date_str, "%d.%m.%Y")
yesterday_22_str = yesterday_dt_obj.strftime("%Y-%m-%d 22:00:00")
save_scud_to_db(merged_yesterday, yesterday_date_str, snapshot_time=yesterday_22_str, is_yesterday=True)
print(f"[✓] Детальный отчет за вчера сформирован и зафиксирован в SQLite за {yesterday_date_str}")
# ============================================================
# 🎯 ЧАСТЬ 2: ЕЖЕДНЕВНАЯ СВОДКА (ЗА ЦЕЛЕВОЙ ДЕНЬ СНАПШОТА)
# ============================================================
if not args.snapshot and not args.skip_export:
save_scud_to_db(raw_scud_today_df, target_date_str, snapshot_time=snapshot_param, is_yesterday=False)
if has_today_1c and df_staff_today is not None and df_absent_today is not None:
print(f"\n[4/5] Обработка и построение Ежедневной сводки за {target_date_str}...")
staff_fios_today_clean = df_staff_today['fio_clean'].dropna().tolist()
if not raw_scud_today_df.empty and 'Пришел' not in raw_scud_today_df.columns:
raw_scud_today_df['Пришел'] = raw_scud_today_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_today_df.columns else False
scud_unrecognized = raw_scud_today_df[~raw_scud_today_df['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist() if not raw_scud_today_df.empty else []
fio_mapping_scud = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_today_clean)
if fio_mapping_scud:
raw_scud_today_df['fio_clean'] = raw_scud_today_df['fio_clean'].apply(
lambda x: fio_mapping_scud[x]['staff_fio'] if x in fio_mapping_scud else x
)
absent_unrecognized_today = df_absent_today[~df_absent_today['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist()
if absent_unrecognized_today:
fio_mapping_absent = ai_verify_scud_against_staff(absent_unrecognized_today, staff_fios_today_clean)
if fio_mapping_absent:
df_absent_today['fio_clean'] = df_absent_today['fio_clean'].apply(
lambda x: fio_mapping_absent[x]['staff_fio'] if x in fio_mapping_absent else x
)
raw_scud_today_df = aggregate_scud_by_employee(raw_scud_today_df, debug=DEBUG)
merged_today = df_staff_today.copy()
if not raw_scud_today_df.empty:
if 'Подразделение' in raw_scud_today_df.columns:
raw_scud_today_df['department_scud'] = raw_scud_today_df['Подразделение']
elif 'department' in raw_scud_today_df.columns:
raw_scud_today_df['department_scud'] = raw_scud_today_df['department']
else:
raw_scud_today_df['department_scud'] = ''
merged_today = merged_today.merge(
raw_scud_today_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']],
on='fio_clean', how='left'
)
if df_absent_today is not None and not df_absent_today.empty:
merged_today = merged_today.merge(df_absent_today[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left')
if 'Пришел' not in merged_today.columns:
merged_today['Пришел'] = False
else:
merged_today['Пришел'] = merged_today['Пришел'].fillna(False)
if 'Сотрудник' not in merged_today.columns:
merged_today['Сотрудник'] = merged_today.get('ФИО', merged_today['fio_clean'])
if static_reasons_dict:
for fio_clean, reason_val in static_reasons_dict.items():
mask_today = (
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_today['fio_clean'] == fio_clean)
)
merged_today.loc[mask_today, 'Вид_отсутствия'] = reason_val
merged_today = apply_exceptions_from_json(merged_today, exceptions_cfg)
mask_exc_today = (
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_today.get('is_excluded', False) == True)
)
merged_today.loc[mask_exc_today, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
scud_fios_today_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set()
merged_today['no_scud_pass'] = (
(~merged_today['fio_clean'].isin(scud_fios_today_set)) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_today.get('is_excluded', False) == False)
)
scud_fios_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set()
anomalies_list = detect_all_anomalies(merged_today, static_reasons_dict, kb_rules, scud_fios_set=scud_fios_set)
mass_failure_today = analyze_scud_mass_failure_ai(raw_scud_today_df)
if mass_failure_today and mass_failure_today.get("is_mass_failure"):
print("\n" + "!" * 60)
print(f"🚨 ВНИМАНИЕ! ИИ ОБНАРУЖИЛ ОПЕРАТИВНЫЙ СБОЙ ТУРНИКЕТОВ ВХОДА СЕГОДНЯ ({mass_failure_today['anomaly_percent']}% СМЕНЫ)")
print(mass_failure_today["alert_text"])
print("!" * 60 + "\n")
save_anomalies_to_db(anomalies_list, target_date_str)
is_no_pass_today = merged_today['no_scud_pass'] == True if 'no_scud_pass' in merged_today.columns else False
is_exc_today = merged_today.get('is_excluded', False) == True
absent_explained = merged_today[
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].notna()) &
(~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
]
absent_unexplained = merged_today[
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(~is_no_pass_today) &
(~is_exc_today)
]
scud_present_but_absent_in_1c = merged_today[
(~is_exc_today) &
(merged_today['Пришел'] == True) &
(merged_today['Вид_отсутствия'].notna()) &
(~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
]
print("[5/5] Запуск ИИ-аудитора и построение Ежедневной сводки...")
filtered_anomalies_list = [
a for a in anomalies_list
if "ОТСУТСТВУЕТ В СКУД" not in a.get('type', '')
]
report_text = generate_markdown_report(
merged_df=merged_today,
absent_explained=absent_explained,
absent_unexplained=absent_unexplained,
scud_present_but_absent_in_1c=scud_present_but_absent_in_1c,
anomalies_list=filtered_anomalies_list,
raw_scud_df=raw_scud_today_df,
raw_absent_df=df_absent_today,
date_str=target_date_str
)
generate_summary_excel(merged_df=merged_today, date_str=target_date_str)
md_report_path = os.path.join(OUTPUT_DIR, f"Сводка_контроллинга_{target_date_str}.md")
with open(md_report_path, "w", encoding="utf-8") as f:
f.write(report_text)
print(f"\n[✓] Текстовый отчет сохранен в: {md_report_path}")
suspicious_cases = []
for a in anomalies_list:
if "ОФИЦИАЛЬНОМ ОТСУТСТВИИ" not in a.get('type', ''):
suspicious_cases.append({
'fio_target': a['fio'],
'reason': f"{a['type']}: {a['details']}"
})
if suspicious_cases:
review_ai_decisions(report_text, suspicious_cases)
print("\n" + "=" * 60)
print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ-АУДИТОРА:")
print("=" * 60)
print(report_text)
else:
print(f"\n[ℹ️] Формирование Ежедневной сводки за {target_date_str} ПРОПУЩЕНО.")
if __name__ == "__main__": if __name__ == "__main__":
+56
View File
@@ -0,0 +1,56 @@
# 🗺️ Дорожная карта задач проекта (ROADMAP)
> **Сформировано:** 2026-08-19 18:40 | **Всего задач:** 16
---
## Модуль `api`
- [x] **#35** [HIGH] `[ЗАВЕРШЕНО]` Создание REST API на FastAPI (/api/v1/snapshots, /api/v1/scud/logs, /api/v1/anomalies)
---
## Модуль `core/database`
- [x] **#22** [HIGH] `[ЗАВЕРШЕНО]` Перевод подключения 1С:ЗУП на продакшн MS SQL сервер ACCOUNT-01 + гибридный фоллбэк
- [x] **#37** [MEDIUM] `[ЗАВЕРШЕНО]` Создание модуля персистентной памяти проекта в SQLite и CLI-инструмента для фиксации архитектурных решений
---
## Модуль `general`
- [x] **#38** [HIGH] `[ЗАВЕРШЕНО]` По запросу, вывести возможности системы, которые уже настроены. Например на запрос о возможностях по СКУД. И изменить стартовую плашку.
- [ ] **#41** [MEDIUM] Полностью отказаться от tool_injector.py и перевести все вызовы на нативный Function Calling языковой модели. LLM будет основным арбитром *(срок: 2026-08-21)*
- [ ] **#44** [LOW] Тестовая-3 *(срок: 2026-08-20)*
---
## Модуль `scripts`
- [x] **#26** [MEDIUM] `[ЗАВЕРШЕНО]` Создание и выполнение скрипта сквозной переиндексации снапшотов с префиксом Y
- [x] **#27** [MEDIUM] `[ЗАВЕРШЕНО]` Реализация CLI-инспектора БД и выравнивание номеров срезов для идеальной верстки в терминале
- [ ] **#33** [MEDIUM] Автоматизация сбора срезов по расписанию (Cron / Systemd Timers на 12:00, 17:00, 19:00)
---
## Модуль `services/ai_verifier`
- [x] **#28** [HIGH] `[ЗАВЕРШЕНО]` Двойная проверка исключений по department_scud и 1С, удержание 100% приоритета кадровых документов
---
## Модуль `services/scud_export`
- [x] **#23** [HIGH] `[ЗАВЕРШЕНО]` Инцидент Равина В.Э.: Фиксация 100% приоритета сырых логов pLogData СКУД над расчетом УРВ Ориона
- [x] **#25** [HIGH] `[ЗАВЕРШЕНО]` Исправление бага дублирования номеров снапшотов (переход на вычисление MAX() по суффиксу)
- [x] **#29** [LOW] `[ЗАВЕРШЕНО]` Лаконичный вывод неразмеченных сотрудников с короткими аббревиатурами отделов СКУД (РУК, ОИЗ, ПУ и др.)
- [ ] **#30** [HIGH] `[В РАБОТЕ]` Внутридневной контроль: Извлечение полных цепочек проходов pLogData (входы/выходы за смену)
- [ ] **#31** [HIGH] `[В РАБОТЕ]` Внутридневной контроль: Расчет количества и суммарной длительности отлучек/перекуров сверх обеда
---
## Модуль `services/text_reporter`
- [ ] **#32** [MEDIUM] `[В РАБОТЕ]` Внутридневной контроль: Вывод списка системных нарушителей внутреннего распорядка в Markdown-отчет
---
View File
+178
View File
@@ -0,0 +1,178 @@
"""
===============================================================================
FILE: modules/ai_engine/agent.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: modules / ai_engine
ROLE: Лаконичный нативный оркестратор Function Calling, диспетчер handlers
и менеджер свободных диалогов (Topic Drift).
AI-CONTEXT-ANCHORS:
- ANCHOR[AGENT_PIPELINE_ENTRY]: Главная точка входа process_chat_message.
===============================================================================
"""
import sys
import json
import logging
from typing import List, Dict, Any, Tuple, Optional
from modules.web_api.llm.schemas import TOOLS_SCHEMA
from modules.web_api.llm.core.ollama_client import call_ollama_chat
from modules.web_api.llm.core.fast_path import handle_fast_path_intercept
from modules.web_api.llm.core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed
from modules.web_api.llm.core.context_manager import mark_last_user_message_ephemeral, close_tool_session_and_cleanup
from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages
from modules.web_api.llm.db.db_prompts import (
db_get_session_state, db_clear_session_state, db_set_session_state,
db_get_stats, db_get_anomalies, db_get_reference
)
from services.knowledge.service import get_rules
from .context_builder import build_agent_system_context
from .handlers.task_handler import handle_tasks_call
from .handlers.prompt_handler import handle_prompt_call
from .handlers.snapshot_handler import handle_snapshots_call
logger = logging.getLogger("AI_AGENT")
logger.setLevel(logging.INFO)
# ANCHOR[AGENT_PIPELINE_ENTRY]
def process_chat_message(
user_id: int,
user_message: str,
file_context: str = "",
image_b64: Optional[str] = None,
chat_history: List[Dict[str, Any]] = None,
session_id: str = "web_session_main"
) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
"""Главный конвейер обработки сообщений чата."""
logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
session_state = db_get_session_state(session_id)
if not session_state:
db_purge_ephemeral_messages(session_id)
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
# 1. Быстрый Fast-Path перехват кнопок подтверждения
fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
if fast_path_res:
return fast_path_res
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
db_history = db_get_chat_history(session_id, limit=20)
system_prompt = build_agent_system_context(user_id, session_state)
user_msg_obj = {"role": "user", "content": full_user_content}
try:
if image_b64:
user_msg_obj["images"] = [image_b64]
messages = [{"role": "system", "content": "Строгий модуль OCR. Перепиши весь текст буква в букву."}, user_msg_obj]
msg = call_ollama_chat(messages, is_vision=True)
else:
clean_history = [dict(m) for m in db_history]
for m in clean_history: m.pop("images", None)
messages = [{"role": "system", "content": system_prompt}] + clean_history + [user_msg_obj]
msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False)
raw_reply = msg.get("content", "")
tool_calls = msg.get("tool_calls", [])
# 2. Гибридный семантический классификатор намерений (Fallback Safety Net)
tool_calls = inject_tools_if_needed(user_message, raw_reply, tool_calls)
# 3. Исполнение инструментов через изолированные handlers
if tool_calls:
tool = tool_calls[0]
fn_name = tool["function"]["name"]
fn_args = tool["function"].get("arguments", {})
if isinstance(fn_args, str):
try: fn_args = json.loads(fn_args)
except Exception: fn_args = {}
logger.info(f"Вызов инструмента: {fn_name} с аргументами: {fn_args}")
close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}")
mark_last_user_message_ephemeral(session_id)
state_data = session_state.get("data_json") or {} if session_state else {}
if fn_name in ["db_get_tasks", "db_tasks_edit", "db_add_task", "db_update_task_status", "db_delete_task"]:
return handle_tasks_call(fn_name, fn_args, user_id, session_id)
elif fn_name in ["db_get_system_prompt", "db_prompt_node_edit"]:
return handle_prompt_call(fn_name, fn_args, session_id)
elif fn_name in ["db_get_snapshots", "db_delete_snapshots"]:
return handle_snapshots_call(fn_name, fn_args, session_id, user_message, state_data)
elif fn_name == "db_get_rules":
res_str = json.dumps(get_rules(), ensure_ascii=False)
elif fn_name == "db_get_stats":
res_str = json.dumps(db_get_stats(), ensure_ascii=False)
elif fn_name == "db_get_anomalies":
res_str = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False)
elif fn_name == "db_get_reference":
res_str = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
else:
res_str = "{}"
messages.append(msg)
messages.append({"role": "tool", "content": res_str})
sec_msg = call_ollama_chat(messages, is_vision=False)
final_content = clean_raw_tool_tags(clean_output(sec_msg.get("content", ""))) or "Запрос выполнен."
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0)
return final_content, db_get_chat_history(session_id), None
# 4. Обычный содержательный диалог и управление Topic Drift
final_reply = clean_raw_tool_tags(clean_output(raw_reply)) or "Запрос обработан."
for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто,", "почемучто", "почему-то"]:
if final_reply.lower().startswith(artifact):
final_reply = final_reply[len(artifact):].lstrip(",.!?:; -")
action_payload = None
# Обработка ответа "нет / спасибо" в режиме открытого инструмента
if session_state and any(kw in user_message.lower() for kw in ["нет", "спасибо", "не надо", "готово", "хватит"]):
close_tool_session_and_cleanup(session_id, close_reason="USER_DISMISSED_TOOL")
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
return final_reply, db_get_chat_history(session_id), None
# Инкремент счётчика шагов в сторону от инструмента (Topic Drift)
if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW", "SNAPSHOTS_VIEW"]:
state_type = session_state.get("state_type")
state_data = session_state.get("data_json") or {}
if not isinstance(state_data, dict):
state_data = {}
idle_turns = state_data.get("idle_turns", 0) + 1
state_data["idle_turns"] = idle_turns
if idle_turns >= 4:
# 4-й шаг не по теме: бесшумно закрываем сессию и вычищаем эфемерные карточки
close_tool_session_and_cleanup(session_id, close_reason="TOPIC_DRIFT_TIMEOUT")
elif idle_turns == 3:
# 3-й шаг: выводим вежливое напоминание с кнопками
tool_label = "системным промптом" if "PROMPT" in state_type else "снапшотами СКУД"
guard_question = f"Желаете продолжить работу с {tool_label}?"
final_reply += f"\n\n💡 *Напоминание:* {guard_question}"
action_payload = {
"type": "FOLLOW_UP_ACTION",
"buttons": [
{"label": "Показать снова", "value": "покажи системный промпт" if "PROMPT" in state_type else "покажи снапшоты", "style": "primary"},
{"label": "Завершить", "value": "нет, спасибо", "style": "secondary"}
]
}
db_set_session_state(session_id, state_type, state_data)
else:
# 1-й и 2-й шаг: фиксируем обновленный счётчик
db_set_session_state(session_id, state_type, state_data)
is_ephem_reply = 1 if "актуальный системный промпт:" in final_reply.lower() else 0
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=is_ephem_reply)
return final_reply, db_get_chat_history(session_id), action_payload
except Exception as ex:
logger.exception(f"Ошибка в агенте: {ex}")
return f"Внутренняя ошибка сервера: {ex}", db_get_chat_history(session_id), None
+84
View File
@@ -0,0 +1,84 @@
"""
===============================================================================
FILE: modules/ai_engine/context_builder.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: modules / ai_engine
ROLE: Динамическая сборка системного промпта из БД, календаря и контекста сессий.
AI-CONTEXT-ANCHORS:
- ANCHOR[CONTEXT_BUILDER_MAIN]: Формирование полного системного контекста для LLM.
===============================================================================
"""
from typing import Dict, Any, Optional
from services.prompts.service import get_active_system_prompt
from modules.web_api.llm.core.calendar_utils import get_dynamic_calendar_context
# ANCHOR[CONTEXT_BUILDER_MAIN]
def build_agent_system_context(
user_id: int,
session_state: Optional[Dict[str, Any]]
) -> str:
"""Формирует полный динамический системный контекст агента с правилами из БД."""
# 1. Загружаем актуальный базовый системный промпт из SQLite БД
base_system_prompt = get_active_system_prompt()
# 2. Серверный календарь и контекст дат
calendar_context = get_dynamic_calendar_context()
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
# 3. Контекст текущего активного стейта сессии
current_state_type = session_state.get("state_type") if session_state else None
state_data = session_state.get("data_json") or {} if session_state else {}
if not isinstance(state_data, dict):
state_data = {}
active_state_context = ""
if current_state_type == "PROMPT_PREVIEW":
active_state_context = (
"\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n"
"- Открыт предпросмотр изменений промпта. Для любых правок вызывай db_prompt_node_edit.\n"
)
elif current_state_type == "PROMPT_FOLLOWUP":
active_state_context = (
"\n[ТЕКУЩИЙ РЕЖИМ: СЕССИЯ РЕДАКТИРОВАНИЯ ПРОМПТА]\n"
"- Оператор просматривает или редактирует системный промпт.\n"
"- На любые команды вида 'удали пункт X.Y' или 'удали X.Y' ТЫ ОБЯЗАН ВЫЗВАТЬ db_prompt_node_edit с action='DELETE', section_id=X, item_id=Y.\n"
"- На любые команды 'добавь пункт X.Y ...' вызывай action='ADD'.\n"
"- Запрещено путать ADD и DELETE.\n"
)
elif current_state_type == "SNAPSHOTS_VIEW":
active_date = state_data.get("query_date", "выбранную дату")
active_state_context = (
f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n"
f"- Сейчас на экране отображаются снапшоты за {active_date}.\n"
f"- Ты можешь форматировать, фильтровать или анализировать этот текущий срез.\n"
f"- Если оператор запрашивает ДРУГУЮ дату или день недели (например: 'за вчера', 'а за 13.08') — "
f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_snapshots(date_str=...).\n"
f"- Запрещено генерировать текст за другую дату по памяти.\n"
)
elif current_state_type == "SNAPSHOT_DELETE_CONFIRM":
active_state_context = "\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ СНАПШОТОВ]\n"
elif current_state_type == "TASK_DELETE_CONFIRM":
active_state_context = "\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧИ]\n"
# 4. Сборка полного системного сообщения
return (
f"{base_system_prompt}\n\n"
f"СТРОГИЕ ПРАВИЛА ВЫЗОВА ИНСТРУМЕНТОВ:\n"
f"1. Для системного промпта:\n"
f" - 'добавь X.Y Текст' -> СРАЗУ вызывай db_prompt_node_edit(action='ADD', section_id=X, item_id=Y, content='Текст').\n"
f" - 'удали X.Y' -> СРАЗУ вызывай db_prompt_node_edit(action='DELETE', section_id=X, item_id=Y, content='').\n"
f" - 'покажи системный промпт' -> СРАЗУ вызывай db_get_system_prompt().\n"
f"2. Для задач:\n"
f" - 'удали задачу N' -> СРАЗУ вызывай db_tasks_edit(action='DELETE', task_id='N').\n"
f" - 'возьми в работу N' -> СРАЗУ вызывай db_tasks_edit(action='UPDATE', task_id='N', status='IN_PROGRESS').\n"
f" - 'заверши N' / 'готово N' -> СРАЗУ вызывай db_tasks_edit(action='UPDATE', task_id='N', status='COMPLETED').\n"
f"3. Запрещено задавать вопросы и писать подтверждения текстом — СРАЗУ вызывай соответствующий инструмент!\n\n"
f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
f"- Пользователь: {user_info}\n"
f"- {calendar_context}\n"
f"{active_state_context}"
)
@@ -0,0 +1,98 @@
"""
===============================================================================
FILE: modules/ai_engine/handlers/prompt_handler.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: modules / ai_engine / handlers
ROLE: Изолированная обработка команд управления системным промптом.
===============================================================================
"""
from typing import Dict, Any, Tuple, Optional
from services.prompts.service import get_active_system_prompt, create_prompt_preview
from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history
from modules.web_api.llm.db.db_prompts import db_set_session_state
# ANCHOR[PROMPT_HANDLER_DISPATCH]
def handle_prompt_call(
fn_name: str,
fn_args: Dict[str, Any],
session_id: str
) -> Tuple[str, list, Optional[Dict[str, Any]]]:
"""Обрабатывает вызовы просмотра и изменения системного промпта."""
# 1. Просмотр промпта с кнопкой быстрого перехода в редактор
if fn_name == "db_get_system_prompt":
active_prompt = get_active_system_prompt()
reply_text = f"Актуальный системный промпт:\n\n{active_prompt}"
# Сохраняем состояние сессии для возможности мгновенного редактирования и подтверждения
db_set_session_state(session_id, "PROMPT_PREVIEW", {
"draft_text": active_prompt,
"action": "MANUAL_EDIT",
"idle_turns": 0
})
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "PROMPT_PREVIEW",
"raw_draft": active_prompt,
"baseline_prompt": active_prompt,
"buttons": [
{"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"},
{"label": "Готово", "value": "нет, спасибо", "style": "secondary"}
]
}
# 2. Предпросмотр точечных или пакетных изменений (ADD / EDIT / DELETE / BATCH_DELETE)
action = str(fn_args.get("action", "ADD")).upper()
nodes_list = fn_args.get("nodes_list", [])
delete_nodes_tuples = []
if nodes_list:
for n_str in nodes_list:
parts = str(n_str).strip().split(".")
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
delete_nodes_tuples.append((int(parts[0]), int(parts[1])))
sec_id = None
itm_id = None
try:
if fn_args.get("section_id") is not None:
sec_id = int(str(fn_args.get("section_id")).strip())
if fn_args.get("item_id") is not None:
itm_id = int(str(fn_args.get("item_id")).strip())
except Exception:
pass
content = str(fn_args.get("content", "")).strip()
merged_prompt, diff_html, baseline_prompt = create_prompt_preview(
action=action,
section_id=sec_id,
item_id=itm_id,
content=content,
delete_nodes=delete_nodes_tuples if delete_nodes_tuples else None
)
db_set_session_state(session_id, "PROMPT_PREVIEW", {
"draft_text": merged_prompt,
"action": "MANUAL_EDIT",
"section_id": sec_id,
"item_id": itm_id,
"content": content,
"idle_turns": 0
})
preview_reply = f"Предпросмотр изменений системного промпта:\n\n{diff_html}\n\nДля применения подтвердите действие, отредактируйте или отмените."
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
return preview_reply, db_get_chat_history(session_id), {
"type": "PROMPT_PREVIEW",
"raw_draft": merged_prompt,
"baseline_prompt": baseline_prompt,
"buttons": [
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"},
{"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
]
}
@@ -0,0 +1,91 @@
"""
===============================================================================
FILE: modules/ai_engine/handlers/snapshot_handler.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: modules / ai_engine / handlers
ROLE: Изолированная обработка запросов просмотра и удаления срезов СКУД.
AI-CONTEXT-ANCHORS:
- ANCHOR[SNAPSHOT_HANDLER_DISPATCH]: Обработка db_get_snapshots и db_delete_snapshots.
===============================================================================
"""
from typing import Dict, Any, Tuple, Optional, List
from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely
from modules.web_api.llm.core.calendar_utils import parse_relative_date_ru
from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history
from modules.web_api.llm.db.db_prompts import db_set_session_state
# ANCHOR[SNAPSHOT_HANDLER_DISPATCH]
def handle_snapshots_call(
fn_name: str,
fn_args: Dict[str, Any],
session_id: str,
user_message: str,
state_data: Dict[str, Any]
) -> Tuple[str, list, Optional[Dict[str, Any]]]:
"""Обрабатывает запросы реестра и безопасного удаления срезов."""
# 1. Получение срезов
if fn_name == "db_get_snapshots":
date_param = fn_args.get("date_str")
if not date_param and user_message:
date_param = parse_relative_date_ru(user_message)
snapshots_res = get_snapshots_registry(date_str=date_param)
query_date = snapshots_res.get("query_date", "выбранную дату")
db_set_session_state(session_id, "SNAPSHOTS_VIEW", snapshots_res)
reply_text = f"Реестр срезов СКУД за {query_date}:"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "SNAPSHOTS_CARD",
"data": snapshots_res
}
# 2. Удаление срезов (двухфазное подтверждение)
raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str")
raw_ids = fn_args.get("snapshot_ids") or []
is_confirmed = fn_args.get("confirmed", False)
if raw_id and not raw_ids:
if isinstance(raw_id, str) and "," in raw_id:
raw_ids = [s.strip() for s in raw_id.split(",")]
else:
raw_ids = [raw_id]
safe_ids = [s.strip() for s in raw_ids if s and not str(s).strip().startswith("Y")]
if not safe_ids:
reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты."
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), None
if not is_confirmed:
query_date = state_data.get("query_date", "")
db_set_session_state(session_id, "SNAPSHOT_DELETE_CONFIRM", {
"snapshot_ids": safe_ids,
"query_date": query_date,
"idle_turns": 0
})
ids_str = ", ".join(safe_ids)
reply_text = f"Вы действительно хотите удалить дневные снапшоты: {ids_str}?"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "SNAPSHOT_DELETE_CONFIRM",
"buttons": [
{"label": f"Удалить ({len(safe_ids)} шт.)", "value": f"подтверждаю удаление снапшотов {ids_str}", "style": "danger"},
{"label": "Отмена", "value": "отмена", "style": "secondary"}
]
}
else:
delete_snapshots_safely(snapshot_ids=safe_ids)
query_date = state_data.get("query_date", "")
updated_data = get_snapshots_registry(date_str=query_date)
reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт."
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "SNAPSHOTS_CARD",
"data": updated_data
}
@@ -0,0 +1,97 @@
"""
===============================================================================
FILE: modules/ai_engine/handlers/task_handler.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: modules / ai_engine / handlers
ROLE: Изолированная обработка вызовов инструментов задач (get, edit, delete, export).
AI-CONTEXT-ANCHORS:
- ANCHOR[TASK_HANDLER_DISPATCH]: Обработка вызовов db_get_tasks и db_tasks_edit.
===============================================================================
"""
from typing import Dict, Any, Tuple, Optional
from services.tasks.service import get_tasks, execute_task_action
from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history
from modules.web_api.llm.db.db_prompts import db_set_session_state
# ANCHOR[TASK_HANDLER_DISPATCH]
def handle_tasks_call(
fn_name: str,
fn_args: Dict[str, Any],
user_id: int,
session_id: str
) -> Tuple[str, list, Optional[Dict[str, Any]]]:
"""Обрабатывает нативные tool-вызовы по задачам."""
# 1. Просмотр задач
if fn_name == "db_get_tasks":
raw_tasks = get_tasks(user_id, status=fn_args.get("status"))
reply_text = "Вот интерактивный список ваших текущих задач:"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "TASK_INTERACTIVE_CARD",
"tasks": raw_tasks
}
# 2. Модификация / Удаление / Экспорт
action = (fn_args.get("action") or "UPDATE").upper()
if fn_name == "db_add_task": action = "ADD"
elif fn_name == "db_delete_task": action = "DELETE"
elif fn_name == "db_update_task_status": action = "UPDATE"
# Двухфазное подтверждение удаления
if action == "DELETE":
task_id_raw = str(fn_args.get("task_id", "")).replace("#", "").replace("TASK-", "").strip()
db_set_session_state(session_id, "TASK_DELETE_CONFIRM", {"task_id": task_id_raw, "idle_turns": 0})
reply_text = f"Вы действительно хотите удалить задачу #{task_id_raw}?"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "TASK_DELETE_CONFIRM",
"buttons": [
{"label": f"Удалить #{task_id_raw}", "value": f"подтверждаю удаление задачи {task_id_raw}", "style": "danger"},
{"label": "Отмена", "value": "отмена", "style": "secondary"}
]
}
# Экспорт в Markdown
elif action == "EXPORT":
export_res = execute_task_action(
user_id=user_id,
action="EXPORT",
filename=fn_args.get("filename"),
status=fn_args.get("status")
)
reply_text = export_res.get("message", "Отчет по задачам успешно экспортирован.")
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
action_payload = None
if export_res.get("status") == "success":
action_payload = {
"type": "FILE_DOWNLOAD_CARD",
"filename": export_res.get("filename"),
"download_url": export_res.get("download_url"),
"tasks_count": export_res.get("tasks_count")
}
return reply_text, db_get_chat_history(session_id), action_payload
# Создание и обновление
else:
res = execute_task_action(
user_id=user_id,
action=action,
task_id=fn_args.get("task_id"),
title=fn_args.get("title"),
priority=fn_args.get("priority", "MEDIUM"),
status=fn_args.get("status"),
module=fn_args.get("module", "general"),
due_date=fn_args.get("due_date")
)
raw_tasks = get_tasks(user_id)
reply_text = res.get("message", "Операция над задачами выполнена.")
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "TASK_INTERACTIVE_CARD",
"tasks": raw_tasks
}
+310 -408
View File
@@ -2,39 +2,32 @@
=============================================================================== ===============================================================================
FILE: modules/web_api/llm/agent.py FILE: modules/web_api/llm/agent.py
PROJECT: SCUD Orion AI (Unified Architecture) PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm (Core Agent & Function Calling Dispatcher) MODULE: web_api / llm (Core Agent Coordinator)
ROLE: Главный оркестратор взаимодействия с Ollama LLM (Qwen 2.5), разбор вызовов ROLE: Нативный оркестратор диалога, диспетчер Function Calling,
инструментов (Function Calling), интеграция с декларативным реестром передача активных срезов в контекст модели и терминальные вызовы.
действий SQLite (tool_action_registry), детерминированный Fast-Path
для подтверждений, отслеживание Topic Drift и Context Guard с кнопками.
=============================================================================== ===============================================================================
""" """
# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_IMPORTS]
import os
import sys import sys
import json import json
import urllib.request
import urllib.error
import logging import logging
import re
from typing import List, Dict, Any, Tuple, Optional from typing import List, Dict, Any, Tuple, Optional
# Импорт фасада базы данных и прямого подключения
from .db.connection import get_db_connection from .db.connection import get_db_connection
from .db_tools import ( from .db_tools import (
db_get_active_system_prompt, db_get_active_system_prompt,
db_add_system_prompt, db_apply_prompt_node_action,
db_get_tool_action, db_get_tool_action,
db_get_tasks, db_get_tasks,
db_update_task_status, db_update_task_details,
db_delete_task, db_delete_task,
db_add_task, db_add_task,
db_tasks_edit,
db_export_tasks_markdown,
db_get_rules, db_get_rules,
db_set_session_state, db_set_session_state,
db_get_session_state, db_get_session_state,
db_clear_session_state, db_clear_session_state,
db_increment_session_idle,
db_get_snapshots, db_get_snapshots,
db_delete_snapshots, db_delete_snapshots,
db_get_current_server_time, db_get_current_server_time,
@@ -43,15 +36,21 @@ from .db_tools import (
db_purge_ephemeral_messages, db_purge_ephemeral_messages,
db_get_stats, db_get_stats,
db_get_anomalies, db_get_anomalies,
db_get_session_states,
db_get_reference db_get_reference
) )
from .schemas import TOOLS_SCHEMA from .schemas import TOOLS_SCHEMA
from .core.calendar_utils import get_dynamic_calendar_context, parse_relative_date_ru 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.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
)
# --- [SECTION 2: LOGGING CONFIGURATION] --- # ANCHOR[LOGGING_CONFIG]
logger = logging.getLogger("SCUD_AGENT") logger = logging.getLogger("SCUD_AGENT")
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
logger.propagate = False logger.propagate = False
@@ -62,12 +61,7 @@ if not logger.handlers:
handler.setFormatter(formatter) handler.setFormatter(formatter)
logger.addHandler(handler) logger.addHandler(handler)
OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
TEXT_MODEL = "qwen2.5:14b"
VISION_MODEL = "qwen2.5vl:7b-q8_0"
# --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] --- # ANCHOR[CHAT_PROCESSOR]
def process_chat_message( def process_chat_message(
user_id: int, user_id: int,
user_message: str, user_message: str,
@@ -77,426 +71,334 @@ def process_chat_message(
session_id: str = "web_session_main" session_id: str = "web_session_main"
) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]: ) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
""" """
Главный конвейер обработки входящего сообщения: Главный конвейер обработки входящего сообщения чата на базе нативного Function Calling.
1. Fast-Path перехват подтверждений/отмен при активном session_state.
2. Перехват завершения работы ('нет, закончить настройку') с автоочисткой эфемерных сообщений.
3. Формирование системного контекста с учетом активного действия (черновика).
4. Выполнение вызванного Tool и опрос Data-Driven реестра действий.
5. Проверка счетчика отвлечений (idle_turns), Context Guard на 3-м шаге и автоочистка при N > 3.
6. Возврат кортежа: (reply_text, chat_history, action_metadata).
""" """
logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}") logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
# 3.1. Обогащение текста вложением (при наличии)
full_user_content = user_message
if file_context:
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}"
session_state = db_get_session_state(session_id) session_state = db_get_session_state(session_id)
user_msg_clean = user_message.lower().strip(" .!?:;") if not session_state:
db_purge_ephemeral_messages(session_id)
# --- [FAST-PATH 1: ПЕРЕХВАТ ЗАВЕРШЕНИЯ НАСТРОЙКИ С ОЧИСТКОЙ ЭФЕМЕРНОЙ ПАМЯТИ] --- full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
if user_msg_clean in ["нет, спасибо", "нет, закончить настройку", "закончить настройку", "завершить", "нет"]:
db_clear_session_state(session_id)
deleted_count = db_purge_ephemeral_messages(session_id)
logger.info(f"Завершена работа с инструментом. Удалено эфемерных сообщений: {deleted_count}")
reply_text = "Хорошо. Настройка завершена, контекст диалога чист. Чем я могу помочь дальше?"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
return reply_text, db_get_chat_history(session_id), None
# --- [FAST-PATH 2: ПЕРЕХВАТ ПОДТВЕРЖДЕНИЯ / ОТМЕНЫ ПРЕВЬЮ ПРОМПТА] --- # 1. Быстрый перехват системных кнопок UI и терминальных действий без задержек LLM
if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
draft_text = "" if fast_path_res:
if session_state.get("data_json") and isinstance(session_state["data_json"], dict): return fast_path_res
draft_text = session_state["data_json"].get("draft_text", "")
else:
draft_text = session_state.get("pending_data", "")
if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: # 2. Фиксация сообщения пользователя
db_add_system_prompt("main_agent", draft_text) db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
# Переводим сессию в режим follow-up диалога
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
action_cfg = db_get_tool_action("db_confirm_prompt_preview")
reply_text = action_cfg["success_template"] if action_cfg else "Системный промпт успешно сохранен и применен в базе данных."
if action_cfg and action_cfg.get("follow_up_question"):
reply_text += f"\n\n{action_cfg['follow_up_question']}"
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
is_eph = action_cfg.get("is_ephemeral", 1) if action_cfg else 1
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1)
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph)
return reply_text, db_get_chat_history(session_id), {
"type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION",
"buttons": action_cfg.get("buttons", []) if action_cfg else []
}
elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]:
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
action_cfg = db_get_tool_action("db_cancel_prompt_preview")
reply_text = action_cfg["success_template"] if action_cfg else "Изменения системного промпта отменены."
if action_cfg and action_cfg.get("follow_up_question"):
reply_text += f"\n\n{action_cfg['follow_up_question']}"
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
is_eph = action_cfg.get("is_ephemeral", 1) if action_cfg else 1
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1)
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph)
return reply_text, db_get_chat_history(session_id), {
"type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION",
"buttons": action_cfg.get("buttons", []) if action_cfg else []
}
# 3.2. Сохраняем входящее сообщение в историю диалога
is_user_ephemeral = 1 if session_state else 0
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=is_user_ephemeral)
# 3.3. Извлекаем полную актуальную историю для передачи в LLM
db_history = db_get_chat_history(session_id, limit=20) db_history = db_get_chat_history(session_id, limit=20)
dynamic_prompt_text = db_get_active_system_prompt()
calendar_context = get_dynamic_calendar_context() calendar_context = get_dynamic_calendar_context()
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
# Информируем модель о наличии незавершенного действия в сессии current_state_type = session_state.get("state_type") if session_state else None
active_state_context = "" state_data = session_state.get("data_json") or {} if session_state else {}
if session_state and session_state.get("state_type") in ["PROMPT_PREVIEW", "PROMPT_FOLLOWUP"]: if not isinstance(state_data, dict):
active_state_context = ( state_data = {}
"\n[АКТИВНОЕ ДЕЙСТВИЕ В СЕССИИ]\n" idle_turns = state_data.get("idle_turns", 0)
"В данный момент оператор находится в процессе настройки системного промпта.\n"
"- Если оператор просит продолжить правки или уточняет детали — продолжай работу с ним.\n"
"- Если оператор переключился на другую тему или вызвал другой инструмент — выполни его команду штатно.\n"
)
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 = ( system_prompt_content = (
f"Ты — интеллектуальный ассистент SCUD Orion AI. " f"Ты — интеллектуальный ассистент SCUD Orion AI.\n"
f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n" f"Твоя основная роль — помощь оператору в кадровом аудите СКУД/1С, управлении задачами и настройками системы.\n\n"
f"[ОКРУЖЕНИЕ]\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.\n"
f"5. Запрещено выдумывать факты и цифры по системе СКУД/1С без вызова инструментов.\n"
f"6. ОБЩИЙ ДИАЛОГ: На любые отвлечённые, познавательные, научные или бытовые вопросы "
f"(расстояние между планетами или городами, программирование, кругозор) отвечай полно, доброжелательно и интересно, не отказывая пользователю.\n\n"
f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
f"- Пользователь: {user_info}\n" f"- Пользователь: {user_info}\n"
f"- {calendar_context}\n" f"- {calendar_context}\n"
f"{active_state_context}\n" f"{active_state_context}"
f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n"
f"1. Для любых изменений системного промпта (добавить, удалить, изменить пункт) ВСЕГДА вызывай функцию db_preview_prompt_merge(prompt_text=...).\n"
f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n"
f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n"
f"4. Никогда не симулируй выполнение функций в виде обычного текста. Если требуется действие — сразу вызывай соответствующий инструмент.\n\n"
f"[ПРИМЕРЫ ВЫЗОВА ИНСТРУМЕНТОВ]:\n"
f"- Пользователь: 'добавь пункт 3.4. Работать от сюда и до заката.' -> Вызов: db_preview_prompt_merge(prompt_text='3.4. Работать от сюда и до заката.')\n"
f"- Пользователь: 'удали пункт 3.4' -> Вызов: db_preview_prompt_merge(prompt_text='3.4')\n"
f"- Пользователь: 'покажи системный промпт' -> Вызов: db_get_system_prompt()\n"
f"- Пользователь: 'покажи мои задачи' -> Вызов: db_get_tasks()\n\n"
f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}"
) )
llm_options = {
"num_predict": 8192,
"num_ctx": 8192,
"temperature": 0.1,
"repeat_penalty": 1.1,
"presence_penalty": 0.5,
"top_p": 0.9
}
user_msg_object = {"role": "user", "content": full_user_content} user_msg_object = {"role": "user", "content": full_user_content}
# --- [SECTION 4: ROUTING & OLLAMA PAYLOAD] --- # ANCHOR[PAYLOAD_BUILD]
if image_b64:
user_msg_object["images"] = [image_b64]
messages = [
{"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву без отсебятины."},
user_msg_object
]
payload = {"model": VISION_MODEL, "messages": messages, "stream": False, "options": llm_options}
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]
payload = {"model": TEXT_MODEL, "messages": messages, "tools": TOOLS_SCHEMA, "stream": False, "options": llm_options}
# --- [SECTION 5: EXECUTION & TOOL ROUTING] --- # ANCHOR[TOOL_ROUTER]
try: try:
req = urllib.request.Request( if image_b64:
OLLAMA_URL, user_msg_object["images"] = [image_b64]
data=json.dumps(payload).encode("utf-8"), messages = [
headers={"Content-Type": "application/json"} {"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву."},
) user_msg_object
with urllib.request.urlopen(req) as response: ]
res_data = json.loads(response.read().decode("utf-8")) msg = call_ollama_chat(messages, is_vision=True)
msg = res_data.get("message", {}) else:
tool_calls = msg.get("tool_calls", []) clean_db_history = [dict(m) for m in db_history]
raw_text_content = msg.get("content", "") 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)
#tool_calls = inject_tools_if_needed(user_message, raw_text_content, tool_calls) raw_text_reply = msg.get("content", "")
tool_calls = msg.get("tool_calls", [])
if tool_calls: if not tool_calls:
logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})") tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls)
messages.append(msg)
for tool in tool_calls:
fn_name = tool["function"]["name"]
fn_args = tool["function"].get("arguments", {})
logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
tool_result_content = ""
action_cfg = db_get_tool_action(fn_name) if tool_calls:
logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})")
if fn_name == "db_confirm_prompt_preview":
if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": tool = tool_calls[0]
draft_text = "" fn_name = tool["function"]["name"]
if session_state.get("data_json") and isinstance(session_state["data_json"], dict): fn_args = tool["function"].get("arguments", {})
draft_text = session_state["data_json"].get("draft_text", "") if isinstance(fn_args, str):
else: try:
draft_text = session_state.get("pending_data", "") fn_args = json.loads(fn_args)
except Exception:
fn_args = {}
db_add_system_prompt("main_agent", draft_text) logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
if action_cfg and action_cfg.get("bypass_llm"):
reply_text = action_cfg["success_template"]
if action_cfg.get("follow_up_question"):
reply_text += f"\n\n{action_cfg['follow_up_question']}"
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
is_eph = action_cfg.get("is_ephemeral", 1)
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph)
return reply_text, db_get_chat_history(session_id), {
"type": action_cfg.get("action_type"),
"buttons": action_cfg.get("buttons", [])
}
tool_result_content = json.dumps({"status": "success"}, ensure_ascii=False)
else:
err_reply = "Нет активного превью для подтверждения."
db_save_chat_message(session_id, "assistant", err_reply, is_ephemeral=1)
return err_reply, db_get_chat_history(session_id), None
elif fn_name == "db_cancel_prompt_preview": # Ротация контекста
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}")
if action_cfg and action_cfg.get("bypass_llm"): session_state = None
reply_text = action_cfg["success_template"] mark_last_user_message_ephemeral(session_id)
if action_cfg.get("follow_up_question"):
reply_text += f"\n\n{action_cfg['follow_up_question']}"
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
is_eph = action_cfg.get("is_ephemeral", 1)
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph)
return reply_text, db_get_chat_history(session_id), {
"type": action_cfg.get("action_type"),
"buttons": action_cfg.get("buttons", [])
}
tool_result_content = json.dumps({"status": "cancelled"}, ensure_ascii=False)
elif fn_name == "db_get_snapshots": # 1. Задачи (Просмотр)
snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message) if fn_name == "db_get_tasks":
tool_result_content = json.dumps(snapshots_res, ensure_ascii=False) 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
}
elif fn_name == "db_get_current_server_time": # 2. Единый диспетчер задач
tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False) 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"
elif fn_name == "db_get_tasks": if action == "DELETE":
tool_result_content = json.dumps(db_get_tasks(user_id), ensure_ascii=False) 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})
elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]: reply_text = f"Вы действительно хотите удалить задачу #{task_id_raw}?"
tool_result_content = json.dumps({"system_prompt": db_get_active_system_prompt()}, ensure_ascii=False) db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
elif fn_name == "db_get_stats": "type": "TASK_DELETE_CONFIRM",
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_session_states":
tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False)
elif fn_name == "db_delete_snapshots":
res = db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str"))
if action_cfg and action_cfg.get("bypass_llm"):
reply_text = action_cfg["success_template"]
if action_cfg.get("follow_up_question"):
reply_text += f"\n\n{action_cfg['follow_up_question']}"
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
is_eph = action_cfg.get("is_ephemeral", 1)
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph)
return reply_text, db_get_chat_history(session_id), {
"type": action_cfg.get("action_type"),
"buttons": action_cfg.get("buttons", [])
}
tool_result_content = json.dumps(res, 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)
# --- [SECTION 6: PROMPT MERGE & PREVIEW ENGINE] --- # ANCHOR[PROMPT_MERGE_LOGIC]
elif fn_name == "db_preview_prompt_merge":
proposed_text = fn_args.get("prompt_text") or fn_args.get("proposed_prompt") or ""
if isinstance(fn_args, str):
proposed_text = fn_args
current_prompt = db_get_active_system_prompt()
user_msg_lower = user_message.lower()
# 1. Обработка удаления пункта
if any(w in user_msg_lower for w in ["удали", "стереть", "убрать", "вырежи", "удалить"]):
target_num_match = re.search(r'\d+(\.\d+)*', user_message)
target_num = target_num_match.group(0) if target_num_match else ""
lines = current_prompt.splitlines()
if target_num:
new_lines = [line for line in lines if not line.strip().startswith(f"{target_num}.")]
else:
new_lines = lines
proposed_text = "\n".join(new_lines)
# 2. Обработка добавления / изменения пункта
elif proposed_text:
if len(proposed_text) < 500:
clean_item = proposed_text.strip()
for prefix in ["добавь пункт", "добавить пункт", "вставь пункт", "добавь"]:
if prefix in clean_item.lower():
clean_item = re.sub(prefix, "", clean_item, flags=re.IGNORECASE).strip(" .:")
lines = current_prompt.splitlines()
new_lines = []
inserted = False
for line in lines:
new_lines.append(line)
if "3.3." in line and not inserted:
item_str = clean_item if re.match(r'^\d+\.\d+\.', clean_item) else f"3.4. {clean_item}"
new_lines.append(f" {item_str}")
inserted = True
if not inserted:
new_lines.append(f" {clean_item}")
proposed_text = "\n".join(new_lines)
# Сохраняем черновик в структурированном виде с idle_turns = 0
state_payload = {
"draft_text": proposed_text,
"idle_turns": 0
}
db_set_session_state(session_id, "PROMPT_PREVIEW", state_payload)
# Помечаем последнее сообщение пользователя как эфемерное
with get_db_connection() as conn_fix:
cursor_fix = conn_fix.cursor()
cursor_fix.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_fix.commit()
preview_reply = (
f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n"
f"{proposed_text}\n\n"
f"Для применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)."
)
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id), {
"type": "PROMPT_PREVIEW",
"buttons": [
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"}
]
}
elif fn_name == "db_get_rules":
tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False)
elif fn_name == "db_add_task":
res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date"))
tool_result_content = json.dumps(res, ensure_ascii=False)
elif fn_name == "db_update_task_status":
res = db_update_task_status(user_id=user_id, task_id=str(fn_args.get("task_id")), status=fn_args.get("status", "COMPLETED"), due_date=fn_args.get("due_date"))
tool_result_content = json.dumps(res, ensure_ascii=False)
elif fn_name == "db_delete_task":
res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper())
if action_cfg and action_cfg.get("bypass_llm"):
reply_text = action_cfg["success_template"]
if action_cfg.get("follow_up_question"):
reply_text += f"\n\n{action_cfg['follow_up_question']}"
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
is_eph = action_cfg.get("is_ephemeral", 1)
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph)
return reply_text, db_get_chat_history(session_id), {
"type": action_cfg.get("action_type"),
"buttons": action_cfg.get("buttons", [])
}
tool_result_content = json.dumps(res, ensure_ascii=False)
messages.append({"role": "tool", "content": tool_result_content})
# --- [SECTION 7: SECONDARY LLM PASS & CONTEXT GUARD] --- # ANCHOR[SECONDARY_PASS]
second_payload = {"model": TEXT_MODEL, "messages": messages, "stream": False, "options": llm_options}
sec_req = urllib.request.Request(OLLAMA_URL, data=json.dumps(second_payload).encode("utf-8"), headers={"Content-Type": "application/json"})
with urllib.request.urlopen(sec_req) as sec_response:
sec_res_data = json.loads(sec_response.read().decode("utf-8"))
raw_content = sec_res_data.get("message", {}).get("content", "").strip().replace("**", "").replace("*", "")
final_content = clean_raw_tool_tags(clean_output(raw_content))
# Определяем, был ли вызов служебного инструмента просмотра промпта
tool_names_called = [t["function"]["name"] for t in tool_calls]
is_output_ephemeral = 1 if any(name in ["db_get_system_prompt", "db_get_system_prompts", "db_preview_prompt_merge"] for name in tool_names_called) else 0
# Отслеживание отвлечений при активном процессе настройки
if session_state and session_state.get("state_type") in ["PROMPT_PREVIEW", "PROMPT_FOLLOWUP"]:
idle_count = db_increment_session_idle(session_id)
logger.info(f"Смена темы ({session_state.get('state_type')}). Текущий idle_turns: {idle_count}")
if idle_count == 3:
guard_note = "\n\nНапоминание: Завершить настройку системного промпта и очистить сессию?"
final_content += guard_note
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral)
return final_content, db_get_chat_history(session_id), {
"type": "FOLLOW_UP_ACTION",
"buttons": [
{"label": "Да, закончить", "value": "нет, закончить настройку", "style": "primary"},
{"label": "Показать промпт", "value": "покажи системный промпт", "style": "secondary"}
]
}
elif idle_count > 3:
logger.info(f"Автоочистка сессии: оператор переключился на другую тему (idle_turns={idle_count}).")
db_clear_session_state(session_id)
db_purge_ephemeral_messages(session_id)
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral)
return final_content, db_get_chat_history(session_id), None
# Если вызовов инструментов не было (обычный текстовый диалог)
raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "")
content_str = clean_raw_tool_tags(clean_output(raw_str))
final_reply = content_str or "Запрос обработан."
if session_state and session_state.get("state_type") in ["PROMPT_PREVIEW", "PROMPT_FOLLOWUP"]:
idle_count = db_increment_session_idle(session_id)
logger.info(f"Текстовый диалог вне настройки ({session_state.get('state_type')}). Текущий idle_turns: {idle_count}")
if idle_count == 3:
guard_note = "\n\nНапоминание: Завершить настройку системного промпта и очистить сессию?"
final_reply += guard_note
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
return final_reply, db_get_chat_history(session_id), {
"type": "FOLLOW_UP_ACTION",
"buttons": [ "buttons": [
{"label": "Да, закончить", "value": "нет, закончить настройку", "style": "primary"}, {"label": f"Удалить #{task_id_raw}", "value": f"подтверждаю удаление задачи {task_id_raw}", "style": "danger"},
{"label": "Показать промпт", "value": "покажи системный промпт", "style": "secondary"} {"label": "Отмена", "value": "отмена", "style": "secondary"}
] ]
} }
elif idle_count > 3:
logger.info(f"Автоочистка сессии: оператор переключился на другую тему (idle_turns={idle_count}).")
db_clear_session_state(session_id)
db_purge_ephemeral_messages(session_id)
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) elif action == "EXPORT":
return final_reply, db_get_chat_history(session_id), None 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: except Exception as ex:
logger.exception(f"Непредвиденная ошибка: {ex}") logger.exception(f"Непредвиденная ошибка агента: {ex}")
error_reply = f"Внутренняя ошибка сервера: {ex}" error_reply = f"Внутренняя ошибка сервера: {ex}"
return error_reply, db_get_chat_history(session_id), None return error_reply, db_get_chat_history(session_id), None
@@ -0,0 +1,64 @@
"""
===============================================================================
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
+317
View File
@@ -0,0 +1,317 @@
"""
===============================================================================
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'<span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{line} [УДАЛЕНИЕ]</span>'
)
elif tag == 'insert':
for line in draft_lines[j1:j2]:
diff_html_lines.append(
f'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{line}</span>'
)
elif tag == 'replace':
for line in base_lines[i1:i2]:
diff_html_lines.append(
f'<span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{line} [УДАЛЕНИЕ]</span>'
)
for line in draft_lines[j1:j2]:
diff_html_lines.append(
f'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{line}</span>'
)
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
+61
View File
@@ -0,0 +1,61 @@
"""
===============================================================================
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", {})
+24 -27
View File
@@ -1,39 +1,36 @@
"""
===============================================================================
FILE: modules/web_api/llm/core/tool_injector.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Базовая санитарная очистка вывода и тегов инструментов.
===============================================================================
"""
import re import re
import json
import logging import logging
from typing import List, Dict, Any from typing import List, Dict, Any
logger = logging.getLogger("TOOL_INJECTOR") logger = logging.getLogger("TOOL_INJECTOR")
def clean_raw_tool_tags(text: str) -> str: def clean_raw_tool_tags(text: str) -> str:
"""Удаляет сырые теги вызова инструментов и системный шум."""
if not text: if not text:
return "" return ""
text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*(</tool_call>)?', '', text) cleaned = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL)
text = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL) cleaned = re.sub(r'<\|.*?\|>', '', cleaned)
text = re.sub(r'</tool_call>\w*\[\]\(\)', '', text) return cleaned.strip()
text = re.sub(r'</tool_call>', '', text)
return text.strip()
def clean_output(text: str) -> str: def clean_output(text: str) -> str:
if not text: """Очищает маркеры форматирования."""
return text return text.strip() if text else ""
artifacts = ["почемучто", "почто", "почему что"]
lower_text = text.lower()
for art in artifacts:
if lower_text.startswith(art):
text = text[len(art):].lstrip(",.!?:; -")
return text.strip()
def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
if tool_calls: def inject_tools_if_needed(user_message: str, raw_reply: str, existing_tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
return tool_calls """
if '{"name":' in raw_text_content or '<tool_call>' in raw_text_content: Модель управляется через TOOLS_SCHEMA и системный контекст.
try: Любые синтетические перехваты текста регулярными выражениями отключены согласно ROADMAP.
match = re.search(r'\{"name":\s*"([^"]+)",\s*"(?:params|arguments|properties)":\s*(\{.*?\})\}', raw_text_content) """
if match: return existing_tool_calls
fn_name = match.group(1)
fn_args = json.loads(match.group(2))
return [{"function": {"name": fn_name, "arguments": fn_args}}]
except Exception as parse_err:
logger.debug(f"Ошибка парсинга сырого tool call: {parse_err}")
return tool_calls
+163 -117
View File
@@ -1,11 +1,13 @@
""" """
===============================================================================
FILE: modules/web_api/llm/db/db_prompts.py FILE: modules/web_api/llm/db/db_prompts.py
PROJECT: SCUD Orion AI PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / db MODULE: web_api / llm / db
ROLE: Управление системными промптами, правилами базы знаний, ROLE: Реляционное управление системным промптом (таблица system_prompt_nodes),
декларативным реестром действий инструментов (tool_action_registry) базой знаний, реестром действий и сессионными стейтами.
и расширенным сессионным состоянием (session_states + idle_turns). ===============================================================================
""" """
import re
import json import json
import logging import logging
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
@@ -14,18 +16,115 @@ from .connection import get_db_connection
logger = logging.getLogger("DB_PROMPTS") logger = logging.getLogger("DB_PROMPTS")
def db_get_active_system_prompt() -> str: def init_prompt_nodes_table():
"""Извлекает актуальный активный системный промпт из SQLite.""" """Создает реляционную таблицу узлов промпта и заполняет базовыми данными."""
conn = get_db_connection() with get_db_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT prompt_text FROM system_prompts WHERE is_active = 1 ORDER BY id DESC LIMIT 1") cursor.execute("""
row = cursor.fetchone() CREATE TABLE IF NOT EXISTS system_prompt_nodes (
conn.close() id INTEGER PRIMARY KEY AUTOINCREMENT,
return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI." 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() 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() 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]]: def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]:
"""Получение шаблона, кнопок и настроек эфемерности инструмента из SQLite."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
@@ -44,33 +143,7 @@ def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]:
return None return None
def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
"""Сохраняет новую версию системного промпта и активирует её."""
try:
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("BEGIN IMMEDIATE;")
cursor.execute("SELECT id FROM system_prompts WHERE name = ?", (name,))
existing = cursor.fetchone()
if existing:
cursor.execute(
"UPDATE system_prompts SET prompt_text = ?, is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE name = ?",
(prompt_text, name)
)
else:
cursor.execute(
"INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)",
(name, prompt_text)
)
conn.commit()
return {"status": "success", "message": "Системный промпт успешно обновлен"}
except Exception as e:
logger.error(f"Ошибка при сохранении промпта в БД: {e}")
return {"status": "error", "error": str(e)}
def db_get_rules() -> List[Dict[str, Any]]: def db_get_rules() -> List[Dict[str, Any]]:
"""Извлекает список глобальных правил компании из ai_knowledge_base."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC") cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC")
@@ -79,23 +152,11 @@ def db_get_rules() -> List[Dict[str, Any]]:
return [dict(r) for r in rows] return [dict(r) for r in rows]
# =============================================================================
# РАБОТА С СЕССИОННЫМИ СОСТОЯНИЯМИ, ЧЕРНОВИКАМИ И СЧЕТЧИКОМ ОТВЛЕЧЕНИЙ (IDLE_TURNS)
# =============================================================================
def db_set_session_state(session_id: str, state_type: str, data: Any) -> None: def db_set_session_state(session_id: str, state_type: str, data: Any) -> None:
""" """Сохраняет состояние сессии в SQLite."""
Сохраняет состояние сессии в SQLite.
Если передан dict/list — автоматически сериализует его в JSON-строку.
"""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() 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 "")
if isinstance(data, (dict, list)):
payload_str = json.dumps(data, ensure_ascii=False)
else:
payload_str = str(data) if data is not None else ""
cursor.execute(""" cursor.execute("""
INSERT INTO session_states (session_id, state_type, pending_data, updated_at) INSERT INTO session_states (session_id, state_type, pending_data, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, ?, CURRENT_TIMESTAMP)
@@ -109,63 +170,23 @@ def db_set_session_state(session_id: str, state_type: str, data: Any) -> None:
def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]: def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]:
"""
Извлекает состояние сессии из SQLite.
Если в pending_data лежит валидный JSON-объект — парсит его в data_json.
"""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute("SELECT session_id, state_type, pending_data, updated_at FROM session_states WHERE session_id = ?", (session_id,))
"SELECT session_id, state_type, pending_data, updated_at FROM session_states WHERE session_id = ?",
(session_id,)
)
row = cursor.fetchone() row = cursor.fetchone()
conn.close() conn.close()
if not row: if not row:
return None return None
res = dict(row) res = dict(row)
raw_data = res.get("pending_data") or "" raw_data = res.get("pending_data") or ""
try: try:
if raw_data.strip().startswith("{") or raw_data.strip().startswith("["): res["data_json"] = json.loads(raw_data) if raw_data.strip().startswith(("{", "[")) else None
res["data_json"] = json.loads(raw_data)
else:
res["data_json"] = None
except Exception: except Exception:
res["data_json"] = None res["data_json"] = None
return res return res
def db_increment_session_idle(session_id: str) -> int:
"""
Инкрементирует счетчик idle_turns для активного черновика сессии.
Возвращает обновленное значение счетчика отвлечений.
"""
state = db_get_session_state(session_id)
if not state:
return 0
data_meta = state.get("data_json")
if isinstance(data_meta, dict):
current_turns = data_meta.get("idle_turns", 0) + 1
data_meta["idle_turns"] = current_turns
db_set_session_state(session_id, state["state_type"], data_meta)
return current_turns
else:
# Если ранее данные были сохранены обычной строкой
new_meta = {
"draft_text": state.get("pending_data", ""),
"idle_turns": 1
}
db_set_session_state(session_id, state["state_type"], new_meta)
return 1
def db_clear_session_state(session_id: str) -> None: def db_clear_session_state(session_id: str) -> None:
"""Сбрасывает и удаляет активное состояние сессии."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
@@ -173,28 +194,10 @@ def db_clear_session_state(session_id: str) -> None:
conn.close() conn.close()
def db_get_session_states() -> Dict[str, Any]:
"""Возвращает реестр всех активных сессий."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT session_id, state_type, updated_at FROM session_states")
rows = cursor.fetchall()
conn.close()
return {"status": "success", "active_sessions": [dict(r) for r in rows]}
# =============================================================================
# СЛУЖЕБНАЯ ДИАГНОСТИКА И СТАТИСТИКА
# =============================================================================
def db_get_stats() -> Dict[str, Any]: def db_get_stats() -> Dict[str, Any]:
"""Возвращает статистику по количеству записей в таблицах."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
tables = [ tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompt_nodes', 'session_states', 'tasks']
'scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history',
'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks'
]
stats = {} stats = {}
for t in tables: for t in tables:
try: try:
@@ -207,7 +210,6 @@ def db_get_stats() -> Dict[str, Any]:
def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]: def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]:
"""Извлекает журнал зафиксированных аномалий."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history" query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history"
@@ -224,7 +226,6 @@ def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[s
def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]: def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]:
"""Извлекает справочные команды и примеры подсказок."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
query = "SELECT category, title, example_prompt, description FROM system_reference" query = "SELECT category, title, example_prompt, description FROM system_reference"
@@ -236,4 +237,49 @@ def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]:
cursor.execute(query, params) cursor.execute(query, params)
rows = cursor.fetchall() rows = cursor.fetchall()
conn.close() conn.close()
return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]} 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)}
-49
View File
@@ -1,49 +0,0 @@
"""
FILE: modules/web_api/llm/db/db_snapshots.py
"""
import json
from typing import Dict, Any, Optional
from .connection import get_db_connection
from .db_prompts import db_set_session_state
def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[str] = None, original_user_message: str = "") -> Dict[str, Any]:
conn = get_db_connection()
cursor = conn.cursor()
query = "SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as record_count FROM scud_logs "
params = []
if date_str:
iso_date = date_str
if "." in date_str:
parts = date_str.split(".")
if len(parts) == 3:
iso_date = f"{parts[2]}-{parts[1]}-{parts[0]}"
query += " WHERE log_date = ? OR log_date = ? OR snapshot_time LIKE ? "
params.extend([date_str, iso_date, f"{iso_date}%"])
query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 50"
cursor.execute(query, params)
rows = cursor.fetchall()
snapshots = [dict(r) for r in rows]
result_data = {
"query_date": date_str or "все",
"snapshots_count": len(snapshots),
"snapshots": snapshots
}
db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=json.dumps(result_data, ensure_ascii=False))
conn.close()
return result_data
def db_delete_snapshots(snapshot_id: Optional[str] = None, day_str: Optional[str] = None) -> Dict[str, Any]:
if not snapshot_id and not day_str:
return {"status": "error", "message": "Необходимо указать snapshot_id или day_str (ДД.ММ.ГГГГ)."}
conn = get_db_connection()
cursor = conn.cursor()
if snapshot_id:
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
else:
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (day_str, f"%{day_str.replace('.', '')}%"))
deleted = cursor.rowcount
conn.commit()
conn.close()
return {"status": "success", "message": f"Успешно удалено записей: {deleted}"}
-82
View File
@@ -1,82 +0,0 @@
"""
FILE: modules/web_api/llm/db/db_tasks.py
"""
import re
from typing import List, Dict, Any, Optional
from .connection import get_db_connection
def normalize_task_id(task_id_input: str) -> str:
if not task_id_input:
return ""
clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "")
if clean_id.isdigit():
num = int(clean_id)
return f"TASK-{(num):02d}" if num < 100 else f"TASK-{(num):03d}"
return f"TASK-{clean_id}"
def db_get_tasks(user_id: int) -> List[Dict[str, Any]]:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, task_id, module, title, priority, status, due_date, created_at
FROM tasks
WHERE user_id = ?
ORDER BY id DESC
""", (user_id,))
rows = cursor.fetchall()
conn.close()
return [dict(r) for r in rows]
def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None) -> Dict[str, Any]:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT MAX(id) FROM tasks")
max_id = cursor.fetchone()[0] or 0
new_task_id = f"TASK-{(max_id + 1):02d}"
cursor.execute("""
INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id)
VALUES (?, ?, ?, ?, 'BACKLOG', ?, ?)
""", (new_task_id, module, title, priority.upper(), due_date, user_id))
conn.commit()
conn.close()
return {"status": "success", "task_id": new_task_id, "message": f"Задача {new_task_id} создана"}
def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED", due_date: Optional[str] = None) -> Dict[str, Any]:
conn = get_db_connection()
cursor = conn.cursor()
formatted_id = normalize_task_id(task_id)
if due_date:
cursor.execute("""
UPDATE tasks
SET status = ?, due_date = ?
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
""", (status.upper(), due_date, formatted_id, f"%{task_id.strip()}", user_id))
else:
cursor.execute("""
UPDATE tasks
SET status = ?
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
""", (status.upper(), formatted_id, f"%{task_id.strip()}", user_id))
if cursor.rowcount == 0:
conn.close()
return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"}
conn.commit()
conn.close()
return {"status": "success", "message": f"Статус задачи {formatted_id} обновлен на {status.upper()}"}
def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
conn = get_db_connection()
cursor = conn.cursor()
formatted_id = normalize_task_id(task_id)
cursor.execute("""
DELETE FROM tasks
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
""", (formatted_id, f"%{task_id.strip()}", user_id))
if cursor.rowcount == 0:
conn.close()
return {"error": f"Задача {task_id} не найдена"}
conn.commit()
conn.close()
return {"status": "success", "message": f"Задача {formatted_id} удалена"}
+86 -19
View File
@@ -1,32 +1,99 @@
""" """
===============================================================================
FILE: modules/web_api/llm/db_tools.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.
===============================================================================
""" """
from datetime import datetime
from .db.connection import DB_PATH, get_db_connection # ANCHOR[FACADE_EXPORTS]
from .db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages from typing import Optional, List, Dict, Any
from .db.db_tasks import normalize_task_id, db_get_tasks, db_add_task, db_update_task_status, db_delete_task from core.connection import DB_PATH, get_connection as get_db_connection
from .db.db_snapshots import db_get_snapshots, db_delete_snapshots
# Домен: Задачи
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 ( from .db.db_prompts import (
db_get_active_system_prompt,
db_add_system_prompt,
db_get_tool_action, db_get_tool_action,
db_get_rules,
db_set_session_state, db_set_session_state,
db_get_session_state, db_get_session_state,
db_clear_session_state, db_clear_session_state,
db_increment_session_idle,
db_get_session_states,
db_get_stats, db_get_stats,
db_get_anomalies, db_get_anomalies,
db_get_reference db_get_reference
) )
from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time
def db_get_current_server_time():
now = datetime.now() def db_add_system_prompt(name_or_text: str, draft_text: str = None) -> None:
days_ru = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"] """Совместимая обертка для сохранения системного промпта."""
return { if draft_text is not None:
"current_date": now.strftime("%d.%m.%Y"), save_full_prompt_draft(draft_text, prompt_name=name_or_text)
"current_time": now.strftime("%H:%M:%S"), else:
"day_of_week": days_ru[now.weekday()], save_full_prompt_draft(name_or_text, prompt_name="main_agent")
"iso_date": now.strftime("%Y-%m-%d")
}
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)
+206 -169
View File
@@ -1,15 +1,206 @@
"""
===============================================================================
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 = [ 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", "type": "function",
"function": { "function": {
"name": "db_get_tasks", "name": "db_get_tasks",
"description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ. Вызывай СРАЗУ при запросе 'покажи мои задачи' или 'список задач'. ВАЖНОЕ ПРАВИЛО ВЫВОДА: Выводи задачи ЕДИНЫМ плоским списком (нумерованным или маркированным) по порядку ID. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО группировать задачи по статусам (В процессе, Бэклог, Завершены) или создавать подзаголовки, если оператор явно не попросил о группировке!", "description": (
"Просмотр реестра задач и бэклога текущего пользователя.\n"
"Вызывай этот инструмент ВСЕГДА при любых запросах просмотра задач ('покажи задачи', 'мои задачи', опечатки 'змдачи').\n"
"Запрещено переспрашивать статус или параметры текстом: просто вызывай функцию с аргументами {}."
),
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"status": { "status": {
"type": "string", "type": "string",
"description": "Фильтр статуса: BACKLOG, IN_PROGRESS или COMPLETED." "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": "Лимит записей"
} }
} }
} }
@@ -19,108 +210,10 @@ TOOLS_SCHEMA = [
"type": "function", "type": "function",
"function": { "function": {
"name": "db_get_rules", "name": "db_get_rules",
"description": "ПОЛУЧИТЬ БАЗУ ЗНАНИЙ ИИ И ПРАВИЛА АРБИТРАЖА (ai_knowledge_base). Вызывай когда пользователь просит показать базу знаний, правила, инструкции или промпты.", "description": "Просмотр базы знаний и правил кадрового арбитража компании.",
"parameters": {"type": "object", "properties": {}}
}
},
{
"type": "function",
"function": {
"name": "db_get_system_prompt",
"description": "ВЫЗЫВАЙ ВСЕГДА при наличии в сообщении фраз: 'покажи системный промпт', 'покажи промпт', 'выведи промпт', 'системный промпт'. Запрещено отвечать текстом без вызова этого инструмента.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": {}, "properties": {}
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "db_update_task_status",
"description": "Изменить статус и/или срок выполнения задачи в реестре.",
"parameters": {
"type": "object",
"properties": {
"task_id": {"type": "string", "description": "Идентификатор задачи, например TASK-17"},
"status": {"type": "string", "description": "Новый статус: COMPLETED, IN_PROGRESS или BACKLOG"},
"due_date": {"type": "string", "description": "Срок выполнения задачи"}
},
"required": ["task_id"]
}
}
},
{
"type": "function",
"function": {
"name": "db_delete_task",
"description": "Удалить задачу из реестра по её task_id (например, TASK-18).",
"parameters": {
"type": "object",
"properties": {
"task_id": {"type": "string", "description": "Идентификатор задачи для удаления, например TASK-18"}
},
"required": ["task_id"]
}
}
},
{
"type": "function",
"function": {
"name": "db_get_snapshots",
"description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СПИСОК СНАПШОТОВ ИЗ БАЗЫ SQLITE. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, даже если список снапшотов уже есть в истории чата или пользователь просит 'обновить', 'повторить запрос', 'проверить снова'. ЗАПРЕЩЕНО беречь контекст и выводить старые данные из истории!",
"parameters": {
"type": "object",
"properties": {
"date_str": {
"type": "string",
"description": "Точная дата в формате ДД.ММ.ГГГГ (например, '12.08.2026'), взятая из [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА]."
}
}
}
}
},
{
"type": "function",
"function": {
"name": "db_delete_snapshots",
"description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.",
"parameters": {
"type": "object",
"properties": {
"snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"},
"day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"}
}
}
}
},
{
"type": "function",
"function": {
"name": "db_get_current_server_time",
"description": "ПОЛУЧИТЬ ТЕКУЩУЮ ДАТУ, ВРЕМЯ И ДЕНЬ НЕДЕЛИ СЕРВЕРА. Вызывай МГНОВЕННО при любых вопросах пользователя про точное текущее время или текущую дату.",
"parameters": {
"type": "object",
"properties": {},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "db_add_task",
"description": "Добавить новую задачу в бэклог проекта.",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Краткое описание задачи"},
"priority": {"type": "string", "description": "Приоритет: HIGH, MEDIUM, LOW"},
"module": {"type": "string", "description": "Модуль проекта, например general или services/scud_export"},
"due_date": {"type": "string", "description": "Срок выполнения задачи, например '2026-08-07 12:00'"}
},
"required": ["title"]
} }
} }
}, },
@@ -128,94 +221,38 @@ TOOLS_SCHEMA = [
"type": "function", "type": "function",
"function": { "function": {
"name": "db_get_stats", "name": "db_get_stats",
"description": "ПОЛУЧИТЬ ОБЩУЮ СТАТИСТИКУ БАЗЫ ДАННЫХ. Вызывай, когда пользователь просит показать общую статистику БД, количество записей в таблицах или размер базы.", "description": "Получение статистики количества записей в таблицах базы данных.",
"parameters": {"type": "object", "properties": {}}
}
},
{
"type": "function",
"function": {
"name": "db_get_anomalies",
"description": "ПОЛУЧИТЬ ИСТОРИЮ АНОМАЛИЙ СКУД ⟷ 1С. Вызывай при запросах на просмотр аномалий или расхождений. Передавай date_str если пользователь просит аномалии за конкретный день, или увеличенный limit (например 100) если просит все.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {}
"limit": {"type": "integer", "description": "Максимальное количество записей (по умолчанию 100)"},
"date_str": {"type": "string", "description": "Опциональная дата в формате ДД.ММ.ГГГГ"}
}
} }
} }
}, },
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "db_get_session_states", "name": "db_get_current_server_time",
"description": "ПОЛУЧИТЬ АКТИВНЫЕ СЕССИИ И ПРЕВЬЮ (session_states). Вызывай, когда пользователь просит показать текущие сессии или статус превью.", "description": "Получение текущего точного времени сервера.",
"parameters": {"type": "object", "properties": {}} "parameters": {
"type": "object",
"properties": {}
}
} }
}, },
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "db_get_reference", "name": "db_get_reference",
"description": "ПОЛУЧИТЬ СИСТЕМНЫЙ СПРАВОЧНИК И ПРИМЕРЫ КОМАНД ДЛЯ ОПЕРАТОРА (system_reference). Вызывай ВСЕГДА, когда пользователь спрашивает про возможности ассистента, список команд, примерах промптов или справе по работе с системой.", "description": "Справка о возможностях ассистента и примеры доступных команд.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"category": { "category": {
"type": "string", "type": "string",
"description": "Фильтр категории: scud, tasks, calendar или system. Если просят всё — не передавай параметр." "description": "Категория справки"
} }
} }
} }
} }
}, }
{
"type": "function",
"function": {
"name": "db_add_system_prompt",
"description": "Прямое сохранение системного промпта в БД без предварительного просмотра.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Имя промпта, по умолчанию main_agent"},
"prompt_text": {"type": "string", "description": "Полный текст системного промпта"}
},
"required": ["prompt_text"]
}
}
},
{
"type": "function",
"function": {
"name": "db_preview_prompt_merge",
"description": "Сформировать предпросмотр изменения системного промпта. Вызывается при любых запросах на добавление ('добавь пункт...'), изменение или удаление пунктов системного промпта ('удали пункт...').",
"parameters": {
"type": "object",
"properties": {
"prompt_text": {
"type": "string",
"description": "Полный текст добавляемого пункта или номер/описание удаляемого пункта."
}
},
"required": ["prompt_text"]
}
}
},
{
"type": "function",
"function": {
"name": "db_confirm_prompt_preview",
"description": "Подтвердить и сохранить текущее подготовленное превью в БД. Вызывай этот инструмент, когда пользователь говорит 'подтверждаю', 'да', 'вноси', 'применяй', 'сохраняй' или одобряет превью в любой форме.",
"parameters": {"type": "object", "properties": {}}
}
},
{
"type": "function",
"function": {
"name": "db_cancel_prompt_preview",
"description": "Отменить текущее превью системного промпта и сбросить изменения. Вызывай, когда пользователь явно отказывается от изменений.",
"parameters": {"type": "object", "properties": {}}
}
}
] ]
+48 -249
View File
@@ -2,68 +2,49 @@
=============================================================================== ===============================================================================
FILE: modules/web_api/main.py FILE: modules/web_api/main.py
PROJECT: SCUD Orion AI (Unified Repository) PROJECT: SCUD Orion AI (Unified Repository)
MODULE: web_api (FastAPI REST Server & Context Management) MODULE: web_api (Main Application Entry Point)
ROLE: Главный шлюз веб-интерфейса, авторизация пользователей (JWT/Bcrypt), ROLE: Инициализация FastAPI приложения, подключение роутеров и статики.
маршрутизация диалогов с LLM, OCR-парсинг файлов и управление задачами.
AI-CONTEXT-ANCHORS & INVARIANTS:
- ANCHOR[SYS_PATH]: Добавляет директорию модуля в sys.path для корректных импортов
независимо от рабочей директории запуска (root или web_api).
- ANCHOR[STATIC_MOUNT]: Рассчитывает абсолютный путь к папке static/ для надежного
рендеринга интерфейса и ассетов (css/js/favicon).
- ANCHOR[AUTH_JWT]: Изолирует персональные пространства задач по user_id (sub).
- ANCHOR[CHAT_PIPELINE]: Оркестрирует пайплайн парсинга вложений (file_parser) и
генерации ответов LLM (agent.process_chat_message).
DEPENDENCIES:
- modules/web_api/llm/agent.py (process_chat_message)
- modules/web_api/llm/db_tools.py (db_get_tasks, DB_PATH)
- modules/web_api/llm/file_parser.py (extract_text_from_file)
=============================================================================== ===============================================================================
""" """
# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_PATH] # ANCHOR[APP_INIT_IMPORTS]
import os import os
import sys import sys
import json
import sqlite3
import logging import logging
import urllib.request
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
# Гарантируем корректный импорт подмодулей web_api независимо от точки запуска
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
if CURRENT_DIR not in sys.path: if CURRENT_DIR not in sys.path:
sys.path.insert(0, CURRENT_DIR) sys.path.insert(0, CURRENT_DIR)
import jwt ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, "../../"))
from passlib.context import CryptContext
from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Form for p in [ROOT_DIR, CURRENT_DIR]:
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials if p not in sys.path:
sys.path.insert(0, p)
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from pydantic import BaseModel from routers.manual_absences import router as manual_absences_router
# Внутренние модули LLM и БД from routers.auth import router as auth_router
from llm.agent import process_chat_message from routers.admin import router as admin_router
from llm.db_tools import db_get_tasks, DB_PATH from routers.tasks import router as tasks_router
from llm.file_parser import extract_text_from_file 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
# --- [SECTION 2: CONFIGURATION & SECURITY] --- # ANCHOR[AUTH_CONFIG] # ANCHOR[APP_CONFIG]
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s", format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler()] handlers=[logging.StreamHandler()]
) )
JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
ALGORITHM = "HS256"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
STATIC_DIR = os.path.join(CURRENT_DIR, "static") STATIC_DIR = os.path.join(CURRENT_DIR, "static")
app = FastAPI(title="SCUD Orion AI Context API", version="2.5") app = FastAPI(title="SCUD Orion AI Context API", version="2.5")
@@ -79,57 +60,21 @@ async def validation_exception_handler(request, exc):
content={"detail": exc.errors(), "body": str(exc)} content={"detail": exc.errors(), "body": str(exc)}
) )
# --- [SECTION 3: DATABASE & TOKEN HELPERS] --- # ANCHOR[DB_HELPERS] # ANCHOR[ROUTER_REGISTRATION]
def get_db(): app.include_router(auth_router)
"""Создает безопасное соединение с SQLite БД модуля.""" app.include_router(admin_router)
conn = sqlite3.connect(DB_PATH) app.include_router(tasks_router)
conn.row_factory = sqlite3.Row app.include_router(chat_router)
return conn 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)
def create_access_token(user_id: int, username: str, is_admin: bool) -> str: # ANCHOR[ROOT_STATIC_ROUTES]
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"},
)
# Pydantic-схемы валидации запросов
class AuthRequest(BaseModel):
username: str
password: str
class CreateUserRequest(BaseModel):
username: str
password: str
full_name: Optional[str] = None
is_admin: Optional[bool] = False
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
# --- [SECTION 4: STATIC FILES & SPA ROUTES] --- # ANCHOR[STATIC_MOUNT]
@app.get("/") @app.get("/")
def read_root(): def read_root():
"""Отдает главную страницу панели управления."""
index_path = os.path.join(STATIC_DIR, "index.html") index_path = os.path.join(STATIC_DIR, "index.html")
if os.path.exists(index_path): if os.path.exists(index_path):
return FileResponse(index_path) return FileResponse(index_path)
@@ -142,175 +87,29 @@ async def favicon():
return FileResponse(file_path) return FileResponse(file_path)
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
# --- [SECTION 5: AUTHENTICATION & USER MANAGEMENT] --- # ANCHOR[AUTH_JWT]
@app.post("/api/v1/auth/login")
def login(req: AuthRequest):
username = req.username.strip().lower()
logging.info(f"===> Попытка входа для пользователя: {username}")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,))
user = cursor.fetchone()
conn.close()
if not user or not pwd_context.verify(req.password, user["password_hash"]):
logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}")
raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль")
is_admin = bool(user["is_admin"]) or (user["username"] == "puh")
token = create_access_token(user["id"], user["username"], is_admin)
logging.info(f"===> УСПЕХ: Авторизован пользователь {username}")
return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin}
@app.post("/api/v1/auth/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()
logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}")
return {"status": "success", "message": "Пароль успешно изменен"}
@app.get("/api/v1/admin/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
@app.post("/api/v1/admin/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} создан"}
@app.delete("/api/v1/admin/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": "Пользователь удален"}
# --- [SECTION 6: TASK TRACKER & LLM CHAT PIPELINE] --- # ANCHOR[CHAT_PIPELINE]
@app.get("/api/v1/tasks")
def get_tasks(user: Dict[str, Any] = Depends(get_current_user)):
"""Получить задачи текущего авторизованного пользователя."""
return db_get_tasks(user_id=user["id"])
# --- ЧАТ С АВТОРИЗАЦИЕЙ ---
@app.post("/api/v1/chat")
async def chat_endpoint(
session_id: str = Form("web_session_main"),
message: str = Form(""),
file: Optional[UploadFile] = File(default=None),
current_user: dict = Depends(get_current_user)
):
parsed_file = {"text": "", "image_b64": None}
if file and file.filename:
file_bytes = await file.read()
parsed_file = extract_text_from_file(file_bytes, file.filename)
reply, history, action_type = process_chat_message(
user_id=current_user["id"],
user_message=message,
file_context=parsed_file["text"],
image_b64=parsed_file["image_b64"],
session_id=session_id
)
return {"reply": reply, "history": history, "action_type": action_type}
# --- ГОСТЕВОЙ ЧАТ ---
@app.post("/api/v1/chat/guest")
async def guest_chat_endpoint(
session_id: str = Form("web_session_main"),
message: str = Form(""),
file: Optional[UploadFile] = File(default=None)
):
parsed_file = {"text": "", "image_b64": None}
if file and file.filename:
file_bytes = await file.read()
parsed_file = extract_text_from_file(file_bytes, file.filename)
reply, history, action_type = process_chat_message(
user_id=0,
user_message=message,
file_context=parsed_file["text"],
image_b64=parsed_file["image_b64"],
session_id=session_id
)
return {"reply": reply, "history": history, "action_type": action_type}
# --- [SECTION 7: STATIC FALLBACK ROUTER] --- # ANCHOR[STATIC_FALLBACK]
@app.get("/{file_path:path}") @app.get("/{file_path:path}")
def serve_static_fallback(file_path: str): def serve_static_fallback(file_path: str):
clean_path = file_path.lstrip("/") 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) target = os.path.join(STATIC_DIR, clean_path)
if os.path.isfile(target): 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) return FileResponse(target)
filename = os.path.basename(clean_path) filename = os.path.basename(clean_path)
target_js = os.path.join(STATIC_DIR, "js", filename) for root, _, files in os.walk(STATIC_DIR):
if filename.endswith(".js") and os.path.isfile(target_js): if filename in files:
return FileResponse(target_js, media_type="application/javascript") full_path = os.path.join(root, filename)
media = "application/javascript" if filename.endswith(".js") else "text/css"
target_css = os.path.join(STATIC_DIR, "css", filename) return FileResponse(full_path, media_type=media)
if filename.endswith(".css") and os.path.isfile(target_css):
return FileResponse(target_css, media_type="text/css")
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
+83
View File
@@ -0,0 +1,83 @@
"""
===============================================================================
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": "Пользователь удален"}
+117
View File
@@ -0,0 +1,117 @@
"""
===============================================================================
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": "Пароль успешно изменен"}
+154
View File
@@ -0,0 +1,154 @@
"""
===============================================================================
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
}
+50
View File
@@ -0,0 +1,50 @@
"""
===============================================================================
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": "Контекст сессии полностью очищен"}
+31
View File
@@ -0,0 +1,31 @@
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("/")
def api_get_exceptions():
return get_all_exceptions_from_db()
@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("/")
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"}
+73
View File
@@ -0,0 +1,73 @@
"""
===============================================================================
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}"
}
)
@@ -0,0 +1,71 @@
"""
===============================================================================
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"}
+157
View File
@@ -0,0 +1,157 @@
"""
===============================================================================
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}
+53
View File
@@ -0,0 +1,53 @@
"""
===============================================================================
FILE: modules/web_api/routers/snapshots.py
ROLE: REST API эндпоинты для управления и моментального создания срезов СКУД.
===============================================================================
"""
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 services.snapshots.service import get_snapshots_registry, delete_snapshots_safely
from services.scud_export import run_export
router = APIRouter(prefix="/api/v1/snapshots", tags=["Snapshots"])
class CreateSnapshotRequest(BaseModel):
date_str: Optional[str] = None
class DeleteSnapshotsRequest(BaseModel):
snapshot_ids: List[str]
@router.get("")
def api_get_snapshots(date_str: Optional[str] = None, current_user = Depends(get_current_user)):
return get_snapshots_registry(date_str=date_str)
@router.post("/create")
def api_create_instant_snapshot(req: CreateSnapshotRequest, current_user = Depends(get_current_user)):
"""Моментальный опрос MS SQL СКУД и запись свежего среза в SQLite."""
try:
success = run_export(input_date=req.date_str, save_xlsx=True, debug=False)
if not success:
raise HTTPException(status_code=500, detail="Ошибка при обращении к MS SQL Орион")
fresh_data = get_snapshots_registry(date_str=req.date_str)
return {"status": "success", "message": "Срез успешно создан", "data": fresh_data}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка создания среза: {str(e)}")
@router.delete("")
def api_delete_snapshots(req: DeleteSnapshotsRequest, current_user = Depends(get_current_user)):
safe_ids = [s for s in req.snapshot_ids if not str(s).startswith("Y")]
if not safe_ids:
raise HTTPException(status_code=400, detail="Итоговый Y-срез защищен от удаления")
res = delete_snapshots_safely(snapshot_ids=safe_ids)
return {"status": "success", "deleted_count": res.get("deleted_count", len(safe_ids))}
+76
View File
@@ -0,0 +1,76 @@
"""
===============================================================================
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
@@ -1,47 +0,0 @@
import os
import sqlite3
# Автопоиск файла базы данных в проекте
db_path = '/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db' if os.path.exists('/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db') else 'scud_orion_ai.db'
print("=" * 80)
print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}")
print("=" * 80)
if not os.path.exists(db_path):
print(f"❌ Файл базы данных {db_path} не найден!")
exit(1)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 1. Список всех таблиц и колонок
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
tables = [t[0] for t in cursor.fetchall()]
print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:")
print("-" * 80)
for t_name in tables:
cursor.execute(f"PRAGMA table_info({t_name})")
cols = [c[1] for c in cursor.fetchall()]
cursor.execute(f"SELECT COUNT(*) FROM {t_name}")
count = cursor.fetchone()[0]
print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}")
# 2. Просмотр правил Базы Знаний
if 'ai_knowledge_base' in tables:
print("\n" + "=" * 80)
print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):")
print("=" * 80)
cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC")
rules = cursor.fetchall()
if not rules:
print("Таблица ai_knowledge_base пуста.")
else:
for r_id, r_text, r_author in rules:
print(f" {r_id}. [{r_author}] {r_text}\n")
conn.close()
print("=" * 80)
@@ -1,23 +0,0 @@
import os
print("=" * 80)
print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_orion_context)")
print("=" * 80)
total_files = 0
total_size = 0
for root, dirs, files in os.walk('.'):
# Исключаем служебные каталоги
dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']]
for f in files:
p = os.path.join(root, f)
size = os.path.getsize(p)
total_files += 1
total_size += size
print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' '))
print("-" * 80)
print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB")
print("=" * 80)
@@ -1,31 +0,0 @@
import os
OUTPUT_SNAPSHOT = "api_code_snapshot.md"
# Расширения файлов для включения в снимок
ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini', '.js', '.html', '.css'}
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_context_api.tar.gz', 'context_memory.db'}
print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...")
with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out:
out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_context_api\n\n")
for root, dirs, files in os.walk('.'):
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
for file in sorted(files):
ext = os.path.splitext(file)[1].lower()
if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES:
filepath = os.path.join(root, file)
out.write(f"## File: `{filepath}`\n")
out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n")
try:
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
out.write(f.read())
except Exception as e:
out.write(f"// Ошибка чтения файла: {e}\n")
out.write("\n```\n\n")
print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)")
+680 -231
View File
@@ -1,261 +1,710 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru" class="h-full bg-slate-100">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SCUD Orion AI — Context Manager</title> <title>SCUD Orion AI Assistant</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/styles.css"> <link rel="icon" href="/favicon.ico" type="image/x-icon">
<style>
/* Запрещаем браузеру насильно удерживать скролл внизу при появлении ответа */
* {
overflow-anchor: none !important;
}
#chat-messages-container, main {
overflow-anchor: none !important;
}
/* ⭐️ Воздух снизу для возможности поднятия вопроса на самый верх */
#chat-messages-container {
padding-bottom: clamp(400px, 85vh, 900px) !important;
}
/* Принудительное увеличение шрифта сообщений чата */
#chat-messages-container .message-content,
#chat-messages-container .text-xs,
#chat-messages-container .text-sm {
font-size: 14.5px !important;
line-height: 1.6 !important;
}
#chat-messages-container pre,
#chat-messages-container code {
font-size: 13.5px !important;
}
/* Смещение для точной прокрутки под фиксированный заголовок */
.user-chat-bubble {
scroll-margin-top: 24px !important;
}
</style>
</head> </head>
<body class="bg-slate-100 text-slate-800 h-[100dvh] w-full flex flex-col font-sans overflow-hidden"> <body class="h-full flex flex-col font-sans antialiased text-slate-800 bg-slate-100 selection:bg-indigo-500 selection:text-white">
<!-- Окно авторизации --> <div id="app-container" class="flex-1 flex overflow-hidden w-full h-full">
<div id="auth-modal" class="fixed inset-0 bg-slate-900/80 backdrop-blur-md z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl p-6 sm:p-8 max-w-md w-full shadow-2xl border border-slate-200"> <!-- ЛЕВАЯ КОЛОНКА (ДИНАМИЧЕСКИЙ САЙДБАР 5-ХАБОВ) -->
<div class="flex items-center space-x-3 mb-6"> <aside class="w-80 md:w-96 bg-white border-r border-slate-200 flex flex-col shrink-0 h-full shadow-sm z-10 select-none">
<div class="bg-indigo-600 text-white p-3 rounded-xl">
<i class="fa-solid fa-user-shield text-xl"></i> <!-- ДИНАМИЧЕСКИЙ ТАБ-БАР ХАБОВ И ПОДВКЛАДОК -->
</div> <div id="sidebar-dynamic-header" class="shrink-0 bg-slate-50 border-b border-slate-200"></div>
<div>
<h2 class="text-lg font-bold text-slate-900">SCUD Orion AI</h2> <!-- ДИНАМИЧЕСКИЙ КОНТЕНТНЫЙ СЛОТ -->
<p class="text-xs text-slate-500">Авторизация в системе</p> <div id="sidebar-dynamic-content" class="flex-1 overflow-y-auto p-2 flex flex-col gap-2">
<div id="tasks-list-container" class="flex-1 flex flex-col gap-2">
<div class="text-center py-10 text-xs text-slate-400">
<i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка...
</div>
</div> </div>
</div> </div>
<div class="space-y-4"> <!-- ПОДВАЛ САЙДБАРА: ПРОФИЛЬ, НАСТРОЙКИ, АДМИНКА И ВЫХОД -->
<div> <div class="p-3 border-t border-slate-200 bg-slate-50 flex items-center justify-between shrink-0">
<label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">Имя пользователя</label> <div class="flex items-center gap-2.5 min-w-0">
<input type="text" id="auth-username-input" placeholder="Введите логин..." required autocomplete="username" <div class="w-8 h-8 rounded-full bg-indigo-600 text-white flex items-center justify-center font-bold text-xs shadow-sm shrink-0">
class="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 focus:outline-none focus:border-indigo-600 focus:bg-white transition"> <i class="fa-solid fa-user"></i>
</div>
<div class="min-w-0 flex flex-col">
<span id="user-display-name" class="text-xs font-bold text-slate-800 truncate">Пользователь</span>
<span id="user-display-role" class="text-[10px] text-slate-400">Оператор</span>
</div>
</div> </div>
<div class="flex items-center gap-1">
<button id="admin-panel-btn" type="button" onclick="openAdminModal()" class="hidden p-1.5 text-slate-400 hover:text-indigo-600 hover:bg-slate-200 rounded-lg transition" title="Управление пользователями">
<i class="fa-solid fa-users-gear text-sm"></i>
</button>
<div> <button type="button" onclick="openProfileModal()" class="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-200 rounded-lg transition" title="Сменить пароль">
<label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">Пароль</label> <i class="fa-solid fa-gear text-sm"></i>
<input type="password" id="auth-password-input" placeholder="Введите пароль..." required autocomplete="current-password" </button>
class="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 focus:outline-none focus:border-indigo-600 focus:bg-white transition">
</div>
<div id="auth-error" class="hidden text-xs text-red-600 font-medium bg-red-50 p-3 rounded-xl border border-red-200"></div> <button type="button" onclick="AuthManager.logout()" class="p-1.5 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition" title="Выйти из системы">
<i class="fa-solid fa-arrow-right-from-bracket text-sm"></i>
<button type="button" onclick="handleLogin()" id="auth-btn" class="w-full bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white font-semibold py-3 rounded-xl text-sm transition shadow-md flex items-center justify-center gap-2">
<i class="fa-solid fa-right-to-bracket"></i>
<span>Войти в систему</span>
</button>
</div>
<div class="relative my-4">
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-200"></div></div>
<div class="relative flex justify-center text-xs uppercase"><span class="bg-white px-2 text-slate-400 font-medium">Или</span></div>
</div>
<button type="button" onclick="enableGuestMode()" class="w-full bg-slate-100 hover:bg-slate-200 text-slate-700 font-semibold py-2.5 rounded-xl text-xs transition border border-slate-300 flex items-center justify-center gap-2">
<i class="fa-solid fa-user-ninja"></i>
<span>Войти как гость (Локальный ИИ)</span>
</button>
</div>
</div>
<!-- Модальное окно смены пароля -->
<div id="change-pwd-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
<div class="bg-white rounded-2xl p-6 max-w-sm w-full shadow-2xl border border-slate-200">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-slate-800 text-sm flex items-center gap-2">
<i class="fa-solid fa-key text-indigo-600"></i> Смена пароля
</h3>
<button type="button" onclick="closeChangePasswordModal()" class="text-slate-400 hover:text-slate-700">
<i class="fa-solid fa-xmark text-lg"></i>
</button>
</div>
<div class="space-y-3">
<div>
<label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Старый пароль</label>
<input type="password" id="old-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
</div>
<div>
<label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Новый пароль</label>
<input type="password" id="new-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
</div>
<div>
<label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Повторите новый пароль</label>
<input type="password" id="confirm-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
</div>
<div id="pwd-error" class="hidden text-xs text-red-600 bg-red-50 p-2 rounded-lg border border-red-200"></div>
<div id="pwd-success" class="hidden text-xs text-emerald-600 bg-emerald-50 p-2 rounded-lg border border-emerald-200"></div>
<button type="button" onclick="handleChangePassword()" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2.5 rounded-xl text-xs transition shadow-sm mt-2">
Сохранить новый пароль
</button>
</div>
</div>
</div>
<!-- Модальное окно управления пользователями -->
<div id="admin-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
<div class="bg-white rounded-2xl p-6 max-w-lg w-full shadow-2xl border border-slate-200 flex flex-col max-h-[85vh]">
<div class="flex justify-between items-center mb-4 pb-2 border-b border-slate-200">
<h3 class="font-bold text-slate-800 text-sm flex items-center gap-2">
<i class="fa-solid fa-users-gear text-indigo-600"></i> Управление пользователями
</h3>
<button type="button" onclick="closeAdminModal()" class="text-slate-400 hover:text-slate-700">
<i class="fa-solid fa-xmark text-lg"></i>
</button>
</div>
<div class="space-y-2 mb-4 bg-slate-50 p-3.5 rounded-xl border border-slate-200 shrink-0">
<p class="text-[11px] font-bold text-slate-700 uppercase">Создать нового пользователя</p>
<div class="grid grid-cols-2 gap-2">
<input type="text" id="new-user-name" placeholder="Логин *" required class="bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
<input type="password" id="new-user-pwd" placeholder="Пароль *" required class="bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
</div>
<input type="text" id="new-user-fullname" placeholder="ФИО (необязательно)" class="w-full bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
<div class="flex items-center justify-between pt-1">
<label class="flex items-center gap-2 text-xs text-slate-700 cursor-pointer">
<input type="checkbox" id="new-user-is-admin" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
<span>Права администратора</span>
</label>
<button type="button" onclick="handleCreateUser()" class="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-4 py-1.5 rounded-lg text-xs transition">
+ Добавить
</button> </button>
</div> </div>
<div id="admin-msg" class="hidden text-[11px] text-red-600 pt-1"></div>
</div> </div>
</aside>
<div class="flex-1 overflow-y-auto space-y-2 pr-1" id="admin-users-list"> <!-- ПРАВАЯ ОБЛАСТЬ (ЧАТ И ИИ-АССИСТЕНТ) -->
<div class="text-xs text-slate-400 py-4 text-center">Загрузка пользователей...</div> <main class="flex-1 flex flex-col h-full min-w-0 bg-slate-50 relative">
</div> <header class="h-14 bg-white border-b border-slate-200 px-4 flex items-center justify-between shrink-0 shadow-sm z-10">
</div> <div class="flex items-center gap-2.5">
</div> <div class="w-7 h-7 rounded-lg bg-indigo-600 text-white flex items-center justify-center shadow-sm">
<i class="fa-solid fa-robot text-xs"></i>
<!-- Хедер --> </div>
<header class="bg-white border-b border-slate-200 px-4 py-2.5 flex justify-between items-center shadow-sm shrink-0 z-20"> <div>
<div class="flex items-center space-x-2.5"> <div class="flex items-center gap-2">
<div class="bg-indigo-600 text-white p-2 rounded-xl shrink-0"> <h1 class="text-sm font-bold text-slate-800">SCUD Orion AI Assistant</h1>
<i class="fa-solid fa-brain text-lg"></i> <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">Online</span>
</div> </div>
<div> <p class="text-[10px] text-slate-400">Система интеллектуального аудита и контроля СКУД / 1С</p>
<h1 class="text-sm font-bold text-slate-900 leading-tight">SCUD Orion AI</h1> </div>
<span class="text-[11px] text-slate-500">Task & Context API</span>
</div>
</div>
<div class="flex items-center space-x-1.5">
<span id="guest-badge" class="hidden text-[10px] text-amber-700 font-semibold bg-amber-50 px-2 py-0.5 rounded-full border border-amber-200">
Гость
</span>
<span id="username-badge" class="hidden text-xs text-indigo-700 font-bold bg-indigo-50 px-2.5 py-1 rounded-full border border-indigo-200">
puh
</span>
<button id="admin-users-btn" type="button" onclick="openAdminModal()" class="hidden text-slate-500 hover:text-indigo-600 transition p-2 rounded-xl" title="Управление пользователями">
<i class="fa-solid fa-users-gear text-base"></i>
</button>
<button id="change-pwd-btn" type="button" onclick="openChangePasswordModal()" class="hidden text-slate-500 hover:text-indigo-600 transition p-2 rounded-xl" title="Сменить пароль">
<i class="fa-solid fa-key text-base"></i>
</button>
<button id="tasks-drawer-btn" type="button" onclick="toggleDrawer()" class="bg-indigo-600 active:bg-indigo-700 text-white px-3 py-1.5 rounded-xl text-xs font-semibold flex items-center gap-1.5 shadow-sm">
<i class="fa-solid fa-list-check"></i>
<span>Задачи</span>
<span id="task-count-badge" class="bg-white text-indigo-700 text-[10px] font-bold px-1.5 py-0.2 rounded-full">0</span>
</button>
<button type="button" onclick="logout()" class="text-slate-400 hover:text-red-600 transition p-2 rounded-xl" title="Выйти">
<i class="fa-solid fa-right-from-bracket text-base"></i>
</button>
</div>
</header>
<!-- Главный контейнер -->
<div class="flex-1 flex flex-col min-h-0 w-full max-w-4xl mx-auto bg-white relative overflow-hidden">
<div id="chat-window" class="flex-1 p-3.5 overflow-y-auto space-y-3 bg-slate-50/50">
<div id="drop-overlay" class="absolute inset-0 bg-indigo-600/10 backdrop-blur-sm border-2 border-dashed border-indigo-600 rounded-2xl hidden flex-col items-center justify-center z-30 transition-all pointer-events-none">
<div class="bg-white p-4 rounded-2xl shadow-xl flex flex-col items-center gap-2">
<i class="fa-solid fa-cloud-arrow-up text-3xl text-indigo-600 animate-bounce"></i>
<p class="text-sm font-bold text-slate-800">Перетащите файл сюда</p>
<p class="text-xs text-slate-500">Поддерживаются PDF, изображения, таблицы, TXT</p>
</div>
</div>
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm">
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
<i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент
</p>
<p class="text-slate-800 text-xs sm:text-sm leading-relaxed">
Привет! У каждого пользователя свое изолированное пространство задач. Вы можете задавать вопросы нейросети, прикреплять файлы или ставить персональные задачи.
</p>
</div>
</div>
<!-- Превью прикрепленного файла -->
<div id="file-preview-container" class="hidden px-4 py-2 bg-slate-100 border-t border-slate-200 flex items-center justify-between text-xs text-slate-700">
<div class="flex items-center gap-2 truncate">
<i class="fa-solid fa-paperclip text-indigo-600"></i>
<span id="file-name-display" class="font-medium truncate">file.pdf</span>
<span id="file-size-display" class="text-slate-400 text-[10px]">(0 KB)</span>
</div>
<button type="button" onclick="clearAttachedFile()" class="text-slate-400 hover:text-red-500 p-1 transition">
<i class="fa-solid fa-xmark text-sm"></i>
</button>
</div>
<div class="p-2.5 pb-6 bg-white border-t border-slate-200 shrink-0 z-10 shadow-lg">
<div class="flex items-center gap-2">
<!-- Скрытый инпут и кнопка прикрепления файла -->
<input type="file" id="file-input" class="hidden" onchange="handleFileSelect(event)" accept=".png,.jpg,.jpeg,.pdf,.txt,.csv,.xlsx">
<button type="button" onclick="document.getElementById('file-input').click()" class="text-slate-500 hover:text-indigo-600 p-2 rounded-xl transition" title="Прикрепить файл">
<i class="fa-solid fa-paperclip text-lg"></i>
</button>
<div class="flex-1 bg-slate-100 border border-slate-300 rounded-2xl px-3 py-1.5 focus-within:border-indigo-600 focus-within:bg-white transition">
<textarea id="user-input" rows="1" autocomplete="off" autocorrect="off" spellcheck="false"
placeholder="Команда, вопрос или перетащите файл сюда..."
class="w-full bg-transparent text-slate-900 text-sm focus:outline-none resize-none overflow-y-auto h-[24px] max-h-[120px] leading-[24px] fade-scroll-top no-scrollbar"></textarea>
</div> </div>
<button type="button" id="send-btn" onclick="sendMessage()" class="bg-indigo-600 active:bg-indigo-800 text-white font-semibold px-3.5 py-2.5 rounded-2xl text-xs sm:text-sm transition flex items-center justify-center gap-1.5 shrink-0 shadow-sm"> </header>
<span>Отправить</span>
<i class="fa-solid fa-paper-plane text-xs"></i> <!-- ЛЕНТА ЧАТА -->
<div id="chat-messages-container" class="flex-1 overflow-y-auto p-4 md:p-6 flex flex-col gap-4">
<div class="flex gap-3 max-w-4xl mx-auto w-full">
<div class="w-7 h-7 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
<i class="fa-solid fa-robot text-xs"></i>
</div>
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
<div class="text-[10px] font-bold text-indigo-600 uppercase tracking-wider mb-1">ИИ-ассистент SCUD Orion AI</div>
<div class="text-xs text-slate-700 leading-relaxed">
Привет! Вы можете задавать вопросы ассистенту, управлять системным промптом, сверять кадровые нестыковки СКУД и 1С или формировать срезы и отчеты.
</div>
</div>
</div>
</div>
<!-- БЕЙДЖ ПРИКРЕПЛЕННОГО ФАЙЛА -->
<div id="file-attachment-preview" class="hidden max-w-4xl mx-auto w-full px-4 pt-2">
<div class="inline-flex items-center gap-2 px-3 py-1 bg-indigo-50 border border-indigo-200 rounded-xl text-xs text-indigo-700 shadow-sm">
<i class="fa-solid fa-file text-indigo-600"></i>
<span id="file-attachment-name" class="font-medium truncate max-w-xs"></span>
<button type="button" onclick="clearAttachedFile()" class="text-indigo-400 hover:text-rose-600 ml-1">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
<!-- Контейнер строки ввода сообщения -->
<div class="px-4 py-2 bg-white border-t border-slate-200">
<div id="chat-input-box" class="max-w-4xl mx-auto w-full flex items-center gap-2 bg-slate-50 border border-slate-300 rounded-xl px-2.5 py-1 transition focus-within:border-indigo-500 focus-within:bg-white focus-within:ring-1 focus-within:ring-indigo-100">
<!-- Скрепка файлов -->
<button type="button" onclick="document.getElementById('file-upload-input').click()" class="text-slate-400 hover:text-indigo-600 p-1 transition shrink-0">
<i class="fa-solid fa-paperclip text-xs"></i>
</button>
<input type="file" id="file-upload-input" class="hidden" />
<!-- Поле ввода -->
<textarea id="user-input" rows="1" placeholder="Команда, вопрос (Enter - отправить, Shift+Enter - перенос строки)..."
class="flex-1 bg-transparent border-0 focus:outline-none text-xs text-slate-800 resize-none py-0 leading-5" style="height: 24px; line-height: 24px;"></textarea>
<!-- Кнопка отправки -->
<button type="button" onclick="window.sendMessage()" class="w-6 h-6 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white flex items-center justify-center shrink-0 shadow-sm transition">
<i class="fa-solid fa-paper-plane text-[10px]"></i>
</button>
</div>
</div>
</main>
</div>
<!-- МОДАЛЬНОЕ ОКНО: УДАЛЕННЫЙ СОТРУДНИК -->
<div id="remote-worker-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-md w-full p-6 flex flex-col gap-4">
<div class="flex items-center justify-between">
<h3 id="remote-modal-title" class="text-sm font-bold text-slate-800 flex items-center gap-2">
<i class="fa-solid fa-house-laptop text-emerald-600"></i>
<span>Параметры удаленной работы</span>
</h3>
<button type="button" onclick="closeRemoteWorkerModal()" class="text-slate-400 hover:text-slate-600">
<i class="fa-solid fa-xmark"></i>
</button> </button>
</div> </div>
<form id="remote-worker-form" onsubmit="handleRemoteWorkerSubmit(event)" class="flex flex-col gap-3">
<input type="hidden" id="rw-mode" value="ADD" />
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника:</label>
<input type="text" id="rw-fio" required placeholder="Например: Иванов Иван Иванович"
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50" />
</div>
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Подразделение:</label>
<input type="text" id="rw-dept" placeholder="Все"
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Дата начала:</label>
<input type="date" id="rw-date-from"
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50 text-slate-700" />
<span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = с сегодняшнего дня</span>
</div>
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Дата окончания:</label>
<input type="date" id="rw-date-to"
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50 text-slate-700" />
<span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = бессрочно</span>
</div>
</div>
<div id="rw-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
<div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
<button type="button" onclick="closeRemoteWorkerModal()"
class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
Отмена
</button>
<button type="submit" id="rw-submit-btn"
class="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow transition">
Сохранить
</button>
</div>
</form>
</div>
</div>
<!-- МОДАЛЬНОЕ ОКНО: МЕСТНАЯ КОМАНДИРОВКА И ИНОЕ С АВТОКОМПЛИТОМ -->
<div id="manual-absence-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-md w-full p-6 flex flex-col gap-4">
<div class="flex items-center justify-between">
<h3 id="manual-absence-modal-title" class="text-sm font-bold text-slate-800 flex items-center gap-2">
<i class="fa-solid fa-location-dot text-indigo-600"></i>
<span>Добавление в реестр</span>
</h3>
<button type="button" onclick="closeManualAbsenceModal()" class="text-slate-400 hover:text-slate-600">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="flex flex-col gap-3">
<!-- Поле ФИО с автодополнением по 1С -->
<div class="relative">
<label class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника (автоподбор из 1С):</label>
<input type="text" id="manual-absence-fio-input" autocomplete="off" placeholder="Начните вводить фамилию..."
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50" />
<div id="manual-absence-suggestions" class="hidden absolute left-0 right-0 top-full mt-1 bg-white border border-slate-300 rounded-lg shadow-xl z-30 max-h-48 overflow-y-auto"></div>
</div>
<input type="hidden" id="manual-absence-dept" />
<input type="hidden" id="manual-absence-pos" />
<!-- Выпадающий список причин (только для "Иное") -->
<div id="manual-absence-reason-block" class="hidden">
<label class="block text-[11px] font-bold text-slate-600 mb-1">Причина отсутствия:</label>
<select id="manual-absence-reason-select" class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50"></select>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Начало:</label>
<input type="date" id="manual-absence-start-date"
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-700" />
<span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = сегодня</span>
</div>
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Окончание:</label>
<input type="date" id="manual-absence-end-date"
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-700" />
<span class="text-[10px] text-slate-400 mt-0.5 block">По умолчанию: сегодня</span>
</div>
</div>
<div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
<button type="button" onclick="closeManualAbsenceModal()"
class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
Отмена
</button>
<button type="button" onclick="submitManualAbsence()"
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition">
Сохранить
</button>
</div>
</div>
</div> </div>
</div> </div>
<!-- Выезжающая панель (Drawer) --> <!-- МОДАЛЬНОЕ ОКНО: ДОБАВЛЕНИЕ В РЕЕСТРЫ ИСКЛЮЧЕНИЙ И ТУРНИКЕТОВ -->
<div id="drawer-backdrop" onclick="toggleDrawer()" class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm hidden transition-opacity z-40"></div> <div id="exception-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-md w-full p-6 flex flex-col gap-4">
<aside id="task-drawer" class="fixed right-0 top-0 h-full w-full sm:w-[420px] bg-white border-l border-slate-200 shadow-2xl transform translate-x-full transition-transform duration-300 ease-in-out z-50 flex flex-col"> <div class="flex items-center justify-between">
<div class="p-3.5 border-b border-slate-200 flex justify-between items-center bg-slate-50 shrink-0"> <h3 id="exception-modal-title" class="text-sm font-bold text-slate-800 flex items-center gap-2">
<h2 class="font-bold text-slate-800 flex items-center gap-2 text-sm"> <i class="fa-solid fa-user-shield text-indigo-600"></i>
<i class="fa-solid fa-list-check text-indigo-600"></i> Мой реестр задач <span id="exception-modal-header-text">Добавление в реестр</span>
</h2> </h3>
<div class="flex items-center gap-3"> <button type="button" onclick="closeExceptionModal()" class="text-slate-400 hover:text-slate-600">
<button type="button" onclick="loadTasks()" class="text-xs text-slate-500 hover:text-indigo-600 transition p-1" title="Обновить"> <i class="fa-solid fa-xmark"></i>
<i class="fa-solid fa-rotate-right text-sm"></i>
</button>
<button type="button" onclick="toggleDrawer()" class="text-slate-500 hover:text-slate-800 transition p-1">
<i class="fa-solid fa-xmark text-lg"></i>
</button> </button>
</div> </div>
</div>
<div class="flex border-b border-slate-200 bg-white px-2 pt-2 text-xs font-semibold text-slate-500 gap-1 overflow-x-auto no-scrollbar shrink-0"> <form id="exception-modal-form" onsubmit="submitExceptionModalForm(event)" class="flex flex-col gap-3">
<button type="button" onclick="setFilter('ALL')" id="filter-ALL" class="px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap">Все</button> <input type="hidden" id="exception-category-input" value="" />
<button type="button" onclick="setFilter('IN_PROGRESS')" id="filter-IN_PROGRESS" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">В работе</button>
<button type="button" onclick="setFilter('BACKLOG')" id="filter-BACKLOG" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">Бэклог</button>
<button type="button" onclick="setFilter('COMPLETED')" id="filter-COMPLETED" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">Завершено</button>
</div>
<div id="tasks-container" class="flex-1 overflow-y-auto p-3.5 space-y-3 bg-slate-50/50 pb-8"> <!-- Поле ввода значения с автокомплитом -->
<div class="text-center text-slate-400 py-8 text-xs">Загрузка ваших задач...</div> <div class="relative">
</div> <label id="exception-value-label" class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника:</label>
</aside> <input type="text" id="exception-value-input" autocomplete="off" required
placeholder="Начните вводить фамилию..."
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-800" />
<!-- Выпадающие подсказки из 1С -->
<div id="exception-suggestions" class="hidden absolute left-0 right-0 top-full mt-1 bg-white border border-slate-300 rounded-lg shadow-xl z-30 max-h-48 overflow-y-auto"></div>
</div>
<script src="/static/js/auth.js"></script> <!-- Опциональный комментарий -->
<script src="/static/js/tasks.js"></script> <div>
<script src="/static/js/chat.js"></script> <label class="block text-[11px] font-bold text-slate-600 mb-1">Примечание / основание (опционально):</label>
<script src="/static/js/app.js"></script> <input type="text" id="exception-comment-input" placeholder="Например: служебная записка, водитель, лаборатория"
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-800" />
</div>
<div id="exception-error-msg" class="text-[11px] font-semibold text-rose-600 hidden"></div>
<div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
<button type="button" onclick="closeExceptionModal()"
class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
Отмена
</button>
<button type="submit" id="exception-submit-btn"
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition flex items-center gap-1.5">
<i class="fa-solid fa-check text-xs"></i>
<span>Добавить</span>
</button>
</div>
</form>
</div>
</div>
<!-- МОДАЛЬНОЕ ОКНО АВТОРИЗАЦИИ -->
<div id="auth-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-sm w-full p-6 flex flex-col gap-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-indigo-600 text-white flex items-center justify-center font-bold text-lg shadow">
<i class="fa-solid fa-shield-halved"></i>
</div>
<div>
<h2 class="text-sm font-bold text-slate-800">Авторизация в системе</h2>
<p class="text-[11px] text-slate-400">SCUD Orion AI Security Access</p>
</div>
</div>
<form id="auth-form" onsubmit="handleLoginSubmit(event)" class="flex flex-col gap-3">
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Имя пользователя:</label>
<input type="text" id="auth-username" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50" placeholder="Логин" />
</div>
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Пароль:</label>
<input type="password" id="auth-password" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50" placeholder="••••••••" />
</div>
<div id="auth-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
<button type="submit" class="w-full py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-bold rounded-lg text-xs shadow transition mt-1">
Войти в систему
</button>
</form>
</div>
</div>
<!-- МОДАЛЬНОЕ ОКНО СМЕНЫ ПАРОЛЯ -->
<div id="profile-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-sm w-full p-6 flex flex-col gap-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-bold text-slate-800 flex items-center gap-1.5">
<i class="fa-solid fa-key text-indigo-600"></i> Смена пароля
</h3>
<button onclick="closeProfileModal()" class="text-slate-400 hover:text-slate-600"><i class="fa-solid fa-xmark"></i></button>
</div>
<form onsubmit="handleChangePassword(event)" class="flex flex-col gap-3">
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Текущий пароль:</label>
<input type="password" id="old-pass" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500" />
</div>
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Новый пароль (мин. 4 симв.):</label>
<input type="password" id="new-pass" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500" />
</div>
<div id="pass-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
<div class="flex items-center justify-end gap-2 mt-2">
<button type="button" onclick="closeProfileModal()" class="px-3 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium">Отмена</button>
<button type="submit" class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition">Сохранить</button>
</div>
</form>
</div>
</div>
<!-- МОДАЛЬНОЕ ОКНО ПАНЕЛИ АДМИНИСТРАТОРА -->
<div id="admin-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-lg w-full p-6 flex flex-col gap-4 max-h-[85vh]">
<div class="flex items-center justify-between">
<h3 class="text-sm font-bold text-slate-800 flex items-center gap-1.5">
<i class="fa-solid fa-users-gear text-indigo-600"></i> Управление учетными записями
</h3>
<button onclick="closeAdminModal()" class="text-slate-400 hover:text-slate-600"><i class="fa-solid fa-xmark"></i></button>
</div>
<form onsubmit="handleCreateUser(event)" class="p-3 bg-slate-50 border border-slate-200 rounded-xl flex flex-col gap-2">
<span class="text-xs font-bold text-slate-700">Создать нового пользователя:</span>
<div class="grid grid-cols-2 gap-2">
<input type="text" id="new-user-username" placeholder="Логин" required class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
<input type="password" id="new-user-password" placeholder="Пароль" required class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
</div>
<input type="text" id="new-user-fullname" placeholder="ФИО" class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
<div class="flex items-center justify-between">
<label class="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer">
<input type="checkbox" id="new-user-admin" class="rounded border-slate-300 text-indigo-600" />
<span>Права администратора</span>
</label>
<button type="submit" class="px-3 py-1 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow">
Создать
</button>
</div>
</form>
<div class="flex-1 overflow-y-auto flex flex-col gap-1.5" id="admin-users-list">
<div class="text-center py-4 text-xs text-slate-400">Загрузка пользователей...</div>
</div>
</div>
</div>
<!-- ПОДКЛЮЧЕНИЕ СКРИПТОВ -->
<script src="/static/js/auth.js?v=2.5.7"></script>
<script src="/static/js/tasks.js?v=2.5.7"></script>
<script src="/static/js/manual_absences.js?v=2.5.7"></script>
<script src="/static/js/sidebar.js?v=2.5.7"></script>
<script src="/static/js/chat/task_widget.js?v=2.5.7"></script>
<script src="/static/js/chat/core.js?v=2.5.7"></script>
<script src="/static/js/app.js?v=2.5.7"></script>
<script>
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");
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.setHub('TASKS');
} else {
const err = await res.json();
errEl.innerText = err.detail || "Неверный логин или пароль";
errEl.classList.remove("hidden");
}
} catch (err) {
errEl.innerText = "Ошибка соединения с сервером";
errEl.classList.remove("hidden");
}
}
function openProfileModal() {
document.getElementById("profile-modal").classList.remove("hidden");
}
function closeProfileModal() {
document.getElementById("profile-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");
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();
errEl.innerText = err.detail || "Ошибка изменения пароля";
errEl.classList.remove("hidden");
}
} catch (e) {
errEl.innerText = "Ошибка сети";
errEl.classList.remove("hidden");
}
}
function openAdminModal() {
document.getElementById("admin-modal").classList.remove("hidden");
loadAdminUsers();
}
function closeAdminModal() {
document.getElementById("admin-modal").classList.add("hidden");
}
async function loadAdminUsers() {
const listEl = document.getElementById("admin-users-list");
listEl.innerHTML = `<div class="text-center py-4 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка...</div>`;
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 => `
<div class="flex items-center justify-between p-2.5 bg-slate-50 border border-slate-200 rounded-xl text-xs">
<div>
<div class="font-bold text-slate-800">${escapeHtml(u.full_name || u.username)} <span class="text-slate-400 font-mono text-[10px]">(${escapeHtml(u.username)})</span></div>
<div class="text-[10px] ${u.is_admin ? 'text-indigo-600 font-bold' : 'text-slate-400'}">${u.is_admin ? 'Администратор' : 'Оператор'}</div>
</div>
<button onclick="deleteAdminUser(${u.id}, '${escapeHtml(u.username)}')" class="text-slate-400 hover:text-rose-600 p-1.5" title="Удалить">
<i class="fa-solid fa-trash-can"></i>
</button>
</div>
`).join('');
} else {
listEl.innerHTML = `<div class="text-center py-4 text-xs text-rose-500">Ошибка загрузки пользователей</div>`;
}
} catch (e) {
listEl.innerHTML = `<div class="text-center py-4 text-xs text-rose-500">Ошибка сети</div>`;
}
}
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("Ошибка сети");
}
}
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");
errEl.classList.add("hidden");
modeInput.value = mode;
if (mode === 'EDIT') {
titleEl.innerHTML = `<i class="fa-solid fa-pen-to-square text-emerald-600"></i><span>Изменение сроков удаленки</span>`;
fioInput.value = fio;
fioInput.readOnly = true;
fioInput.classList.add("bg-slate-100", "text-slate-500", "cursor-not-allowed");
deptInput.value = dept || "Все";
deptInput.readOnly = true;
deptInput.classList.add("bg-slate-100", "text-slate-500", "cursor-not-allowed");
fromInput.value = dmyToYmd(dateFrom);
toInput.value = dmyToYmd(dateTo);
} else {
titleEl.innerHTML = `<i class="fa-solid fa-house-laptop text-emerald-600"></i><span>Добавление удаленщика</span>`;
fioInput.value = "";
fioInput.readOnly = false;
fioInput.classList.remove("bg-slate-100", "text-slate-500", "cursor-not-allowed");
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];
fromInput.value = today;
toInput.value = "";
}
modal.classList.remove("hidden");
}
function closeRemoteWorkerModal() {
document.getElementById("remote-worker-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");
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.renderContent();
} else {
const err = await res.json();
errEl.innerText = err.detail || "Ошибка сохранения";
errEl.classList.remove("hidden");
}
} catch (err) {
errEl.innerText = "Ошибка соединения с сервером";
errEl.classList.remove("hidden");
}
}
document.addEventListener("DOMContentLoaded", () => {
if (window.AuthManager && AuthManager.isAuthenticated()) {
updateUIState();
}
});
</script>
<!-- Глобальная Drag-and-Drop зона на весь экран -->
<div id="global-drag-overlay"
class="fixed inset-0 bg-indigo-900/40 backdrop-blur-xs z-50 hidden flex items-center justify-center pointer-events-none transition-all duration-200">
<div class="bg-white border-2 border-dashed border-indigo-500 rounded-3xl p-10 flex flex-col items-center gap-3 shadow-2xl scale-100 transition-transform">
<div class="w-16 h-16 rounded-2xl bg-indigo-50 text-indigo-600 flex items-center justify-center text-3xl shadow-inner">
<i class="fa-solid fa-cloud-arrow-up animate-bounce"></i>
</div>
<div class="text-base font-bold text-slate-800">Перетащите файл в любую точку окна</div>
<div class="text-xs text-slate-500 font-medium">PDF-документы, сканы, отчеты или изображения</div>
</div>
</div>
</body> </body>
</html> </html>
+64 -297
View File
@@ -1,308 +1,75 @@
function showAuthModal() { /**
const el = document.getElementById("auth-modal"); * ===============================================================================
if (el) el.classList.remove("hidden"); * FILE: modules/web_api/static/js/auth.js
} * ROLE: Менеджер сессий, токенов, ФИО и авторизационных заголовков.
* ===============================================================================
*/
function hideAuthModal() { const AUTH_STORAGE_KEY = "scud_auth_token";
const el = document.getElementById("auth-modal"); const USERNAME_STORAGE_KEY = "scud_username";
if (el) el.classList.add("hidden"); const FULLNAME_STORAGE_KEY = "scud_full_name";
} const IS_ADMIN_STORAGE_KEY = "scud_is_admin";
const USER_ID_STORAGE_KEY = "scud_user_id";
async function handleLogin(e) { const AuthManager = {
if (e && e.preventDefault) e.preventDefault(); getToken() {
return localStorage.getItem(AUTH_STORAGE_KEY) || "";
const usernameInput = document.getElementById("auth-username-input"); },
const passwordInput = document.getElementById("auth-password-input");
const errorEl = document.getElementById("auth-error");
if (!usernameInput || !passwordInput) return; getUserId() {
const uid = localStorage.getItem(USER_ID_STORAGE_KEY);
return uid ? parseInt(uid, 10) : 1;
},
const username = usernameInput.value.trim(); getUsername() {
const password = passwordInput.value; return localStorage.getItem(USERNAME_STORAGE_KEY) || "";
},
if (!username || !password) return; getFullName() {
return localStorage.getItem(FULLNAME_STORAGE_KEY) || this.getUsername() || "Пользователь";
},
if (errorEl) errorEl.classList.add("hidden"); isAdmin() {
return localStorage.getItem(IS_ADMIN_STORAGE_KEY) === "true";
},
try { isAuthenticated() {
const res = await fetch("/api/v1/auth/login", { return Boolean(this.getToken());
method: "POST", },
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }) 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=/`;
}); });
const data = await res.json(); window.location.href = "/";
if (res.status === 200) {
// Используем прямой строковый ключ, чтобы избежать ошибки ReferenceError
API_TOKEN = data.token;
CURRENT_USERNAME = data.username;
IS_ADMIN = data.is_admin;
IS_GUEST = false;
localStorage.setItem("scud_api_auth_token", data.token);
localStorage.setItem("scud_username", data.username);
localStorage.setItem("scud_is_admin", data.is_admin ? "true" : "false");
localStorage.removeItem("scud_is_guest");
hideAuthModal();
updateUIState();
if (typeof loadTasks === 'function') {
loadTasks();
}
} else {
if (errorEl) {
errorEl.innerText = data.detail || "Ошибка авторизации";
errorEl.classList.remove("hidden");
}
}
} catch (err) {
console.error("[Auth Error]", err);
if (errorEl) {
errorEl.innerText = "Ошибка соединения с сервером";
errorEl.classList.remove("hidden");
}
} }
} };
function enableGuestMode() { window.AuthManager = AuthManager;
IS_GUEST = true; window.logout = () => AuthManager.logout();
API_TOKEN = "";
CURRENT_USERNAME = "Гость";
IS_ADMIN = false;
localStorage.setItem("scud_is_guest", "true");
hideAuthModal();
updateUIState();
}
function logout() {
localStorage.removeItem("scud_api_auth_token");
localStorage.removeItem("scud_username");
localStorage.removeItem("scud_is_admin");
localStorage.removeItem("scud_is_guest");
API_TOKEN = "";
CURRENT_USERNAME = "";
IS_ADMIN = false;
IS_GUEST = false;
showAuthModal();
}
function updateUIState() {
const tasksBtn = document.getElementById("tasks-drawer-btn");
const adminBtn = document.getElementById("admin-users-btn");
const changePwdBtn = document.getElementById("change-pwd-btn");
const guestBadge = document.getElementById("guest-badge");
const usernameBadge = document.getElementById("username-badge");
if (typeof IS_GUEST !== 'undefined' && IS_GUEST) {
if (tasksBtn) tasksBtn.classList.add("hidden");
if (adminBtn) adminBtn.classList.add("hidden");
if (changePwdBtn) changePwdBtn.classList.add("hidden");
if (guestBadge) guestBadge.classList.remove("hidden");
if (usernameBadge) usernameBadge.classList.add("hidden");
} else {
if (tasksBtn) tasksBtn.classList.remove("hidden");
if (changePwdBtn) changePwdBtn.classList.remove("hidden");
if (guestBadge) guestBadge.classList.add("hidden");
if (usernameBadge) {
usernameBadge.innerText = (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME) ? CURRENT_USERNAME : "User";
usernameBadge.classList.remove("hidden");
}
if (adminBtn) {
const isAdminUser = (typeof IS_ADMIN !== 'undefined' && IS_ADMIN) || (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME === "puh");
if (isAdminUser) {
adminBtn.classList.remove("hidden");
} else {
adminBtn.classList.add("hidden");
}
}
}
}
function openChangePasswordModal() {
const el = document.getElementById("change-pwd-modal");
if (el) el.classList.remove("hidden");
}
function closeChangePasswordModal() {
const el = document.getElementById("change-pwd-modal");
if (el) el.classList.add("hidden");
const err = document.getElementById("pwd-error");
const succ = document.getElementById("pwd-success");
if (err) err.classList.add("hidden");
if (succ) succ.classList.add("hidden");
document.getElementById("old-pwd-input").value = "";
document.getElementById("new-pwd-input").value = "";
const confirmInput = document.getElementById("confirm-pwd-input");
if (confirmInput) confirmInput.value = "";
}
async function handleChangePassword(e) {
if (e && e.preventDefault) e.preventDefault();
const old_password = document.getElementById("old-pwd-input").value;
const new_password = document.getElementById("new-pwd-input").value;
const confirmInput = document.getElementById("confirm-pwd-input");
const confirm_password = confirmInput ? confirmInput.value : new_password;
const errorEl = document.getElementById("pwd-error");
const successEl = document.getElementById("pwd-success");
if (errorEl) errorEl.classList.add("hidden");
if (successEl) successEl.classList.add("hidden");
if (new_password !== confirm_password) {
if (errorEl) {
errorEl.innerText = "Новые пароли не совпадают";
errorEl.classList.remove("hidden");
}
return;
}
try {
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const res = await fetch("/api/v1/auth/change-password", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify({ old_password, new_password })
});
const data = await res.json();
if (res.status === 200) {
if (successEl) {
successEl.innerText = "Пароль успешно изменен!";
successEl.classList.remove("hidden");
}
setTimeout(closeChangePasswordModal, 1500);
} else {
if (errorEl) {
errorEl.innerText = data.detail || "Ошибка при смене пароля";
errorEl.classList.remove("hidden");
}
}
} catch (err) {
if (errorEl) {
errorEl.innerText = "Ошибка соединения с сервером";
errorEl.classList.remove("hidden");
}
}
}
function openAdminModal() {
const el = document.getElementById("admin-modal");
if (el) el.classList.remove("hidden");
loadUsersList();
}
function closeAdminModal() {
const el = document.getElementById("admin-modal");
if (el) el.classList.add("hidden");
}
async function loadUsersList() {
const listEl = document.getElementById("admin-users-list");
if (!listEl) return;
listEl.innerHTML = '<div class="text-xs text-slate-400 py-4 text-center">Загрузка пользователей...</div>';
try {
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const res = await fetch("/api/v1/admin/users", {
headers: { "Authorization": "Bearer " + token }
});
const users = await res.json();
if (res.status === 200) {
listEl.innerHTML = users.map(u => {
const adminTag = u.is_admin ? '<span class="ml-1.5 text-[9px] bg-indigo-100 text-indigo-700 px-1.5 py-0.5 rounded font-bold">ADMIN</span>' : '<span class="ml-1.5 text-[9px] bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded">USER</span>';
const fullNameHtml = u.full_name ? `<div class="text-[11px] text-slate-500 font-normal">${u.full_name}</div>` : '';
const dateStr = u.created_at ? u.created_at.split(' ')[0] : '—';
const deleteBtn = u.username !== CURRENT_USERNAME ? `<button type="button" onclick="deleteUser(${u.id}, '${u.username}')" class="text-red-500 hover:text-red-700 p-1"><i class="fa-solid fa-trash-can"></i></button>` : '<span class="text-[10px] text-slate-400">Вы</span>';
return `
<div class="flex justify-between items-center bg-slate-50 border border-slate-200 p-2.5 rounded-xl text-xs">
<div>
<div class="flex items-center">
<span class="font-bold text-slate-800">${u.username}</span>
${adminTag}
</div>
${fullNameHtml}
<div class="text-[10px] text-slate-400 mt-0.5">Создан: ${dateStr}</div>
</div>
${deleteBtn}
</div>
`;
}).join("");
} else {
listEl.innerHTML = `<div class="text-xs text-red-500 py-2">${users.detail}</div>`;
}
} catch (err) {
listEl.innerHTML = '<div class="text-xs text-red-500 py-2">Ошибка загрузки пользователей</div>';
}
}
async function handleCreateUser(e) {
if (e && e.preventDefault) e.preventDefault();
const username = document.getElementById("new-user-name").value.trim();
const password = document.getElementById("new-user-pwd").value;
const fullNameInput = document.getElementById("new-user-fullname");
const full_name = fullNameInput ? fullNameInput.value.trim() : "";
const adminCheckbox = document.getElementById("new-user-is-admin");
const is_admin = adminCheckbox ? adminCheckbox.checked : false;
const msgEl = document.getElementById("admin-msg");
if (msgEl) msgEl.classList.add("hidden");
try {
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const res = await fetch("/api/v1/admin/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify({ username, password, full_name, is_admin })
});
const data = await res.json();
if (res.status === 200) {
document.getElementById("new-user-name").value = "";
document.getElementById("new-user-pwd").value = "";
if (fullNameInput) fullNameInput.value = "";
if (adminCheckbox) adminCheckbox.checked = false;
loadUsersList();
} else {
if (msgEl) {
msgEl.innerText = data.detail || "Ошибка";
msgEl.classList.remove("hidden");
}
}
} catch (err) {
if (msgEl) {
msgEl.innerText = "Ошибка связи с сервером";
msgEl.classList.remove("hidden");
}
}
}
async function deleteUser(userId, username) {
if (!confirm("Удалить пользователя " + username + "?")) return;
try {
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
await fetch("/api/v1/admin/users/" + userId, {
method: "DELETE",
headers: { "Authorization": "Bearer " + token }
});
loadUsersList();
} catch (err) {
alert("Ошибка при удалении");
}
}
-321
View File
@@ -1,321 +0,0 @@
// Вспомогательная функция для автоматического изменения высоты текстового поля
function updateInputHeight(el) {
if (!el) return;
el.style.height = "24px";
const newHeight = Math.min(el.scrollHeight, 120);
el.style.height = newHeight + "px";
}
let selectedFile = null;
function handleFileSelect(e) {
const file = e.target.files[0];
if (!file) return;
if (file.size > 15 * 1024 * 1024) {
alert("Файл слишком большой. Максимальный размер: 15 МБ");
e.target.value = "";
return;
}
selectedFile = file;
const fileNameEl = document.getElementById("file-name-display");
const fileSizeEl = document.getElementById("file-size-display");
const previewContainer = document.getElementById("file-preview-container");
if (fileNameEl) fileNameEl.innerText = file.name;
if (fileSizeEl) fileSizeEl.innerText = `(${(file.size / 1024).toFixed(1)} KB)`;
if (previewContainer) previewContainer.classList.remove("hidden");
}
function clearAttachedFile() {
selectedFile = null;
const fileInput = document.getElementById("file-input");
const previewContainer = document.getElementById("file-preview-container");
if (fileInput) fileInput.value = "";
if (previewContainer) previewContainer.classList.add("hidden");
}
// Деактивация всех старых кнопок в истории
function disableAllActionButtons() {
const allBtnContainers = document.querySelectorAll(".action-buttons-container");
allBtnContainers.forEach(container => {
container.querySelectorAll("button").forEach(btn => {
btn.disabled = true;
btn.classList.add("opacity-40", "cursor-not-allowed");
});
});
}
// Быстрая отправка текста кнопки
function handleActionButtonClick(text) {
disableAllActionButtons();
const input = document.getElementById("user-input");
if (input) {
input.value = text;
sendMessage();
}
}
async function sendMessage(e) {
if (e && e.preventDefault) e.preventDefault();
const input = document.getElementById("user-input");
const chatWindow = document.getElementById("chat-window");
const sendBtn = document.getElementById("send-btn");
if (!input || !chatWindow) return;
const text = input.value.trim();
if (!text && !selectedFile) return;
// Деактивируем предыдущие интерактивные кнопки
disableAllActionButtons();
let userDisplayHtml = escapeHtml(text);
if (selectedFile) {
userDisplayHtml = `<div class="font-bold border-b border-indigo-400/40 pb-1 mb-1 text-[11px] flex items-center gap-1.5">
<i class="fa-solid fa-file"></i> ${escapeHtml(selectedFile.name)}
</div>` + userDisplayHtml;
}
const userMsgHtml = `
<div class="flex justify-end mb-3">
<div class="bg-indigo-600 text-white rounded-2xl px-4 py-2.5 max-w-2xl text-xs sm:text-sm shadow-sm">
${userDisplayHtml}
</div>
</div>
`;
chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
input.value = "";
updateInputHeight(input);
chatWindow.scrollTop = chatWindow.scrollHeight;
if (sendBtn) {
sendBtn.disabled = true;
sendBtn.classList.add("opacity-50");
}
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true");
const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat";
const formData = new FormData();
formData.append("session_id", "web_session_main");
formData.append("message", text || "Проанализируй прикрепленный файл");
if (selectedFile instanceof File) {
formData.append("file", selectedFile, selectedFile.name);
}
const headers = {};
if (!isGuest && token) {
headers["Authorization"] = "Bearer " + token;
}
try {
const res = await fetch(endpoint, {
method: "POST",
headers: headers,
body: formData
});
if (res.status === 401 && !isGuest) {
if (typeof logout === 'function') logout();
return;
}
const data = await res.json();
const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI";
const replyText = data.reply || "Пустой ответ от нейросети";
// Генерация блока кнопок подтверждения при необходимости
let actionButtonsHtml = "";
const actionData = data.action_type || data.action;
if (actionData && actionData.buttons && actionData.buttons.length > 0) {
const buttonsMarkup = actionData.buttons.map(btn => {
let btnClasses = "bg-slate-100 hover:bg-slate-200 active:bg-slate-300 text-slate-700 border border-slate-300";
let iconMarkup = '<i class="fa-solid fa-arrow-right text-[10px] opacity-60"></i>';
const labelLower = (btn.label || "").toLowerCase();
const isPrimary = btn.style === "primary" || labelLower.includes("подтверд") || labelLower.startsWith("да");
const isDanger = btn.style === "danger" || labelLower.includes("отмен") || labelLower.startsWith("нет") || labelLower.includes("законч") || labelLower.includes("заверш");
if (isPrimary) {
btnClasses = "bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm";
iconMarkup = '<i class="fa-solid fa-check"></i>';
} else if (isDanger) {
btnClasses = "bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300";
iconMarkup = '<i class="fa-solid fa-xmark"></i>';
}
return `
<button type="button" onclick="handleActionButtonClick('${escapeHtml(btn.value)}')"
class="${btnClasses} font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
${iconMarkup}
<span>${escapeHtml(btn.label)}</span>
</button>
`;
}).join("");
actionButtonsHtml = `
<div class="action-buttons-container flex flex-wrap items-center gap-2 mt-3 pt-2.5 border-t border-slate-100">
${buttonsMarkup}
</div>
`;
}
const botMsgHtml = `
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm max-w-2xl mb-3">
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
<i class="fa-solid fa-robot mr-1"></i> ${assistantTitle}
</p>
<p class="text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${escapeHtml(replyText)}</p>
${actionButtonsHtml}
</div>
`;
chatWindow.insertAdjacentHTML("beforeend", botMsgHtml);
chatWindow.scrollTop = chatWindow.scrollHeight;
clearAttachedFile();
if (!isGuest && typeof loadTasks === 'function') {
loadTasks();
}
} catch (err) {
console.error("[Chat Error]", err);
const errorHtml = `
<div class="bg-red-50 border border-red-200 rounded-2xl p-3.5 max-w-2xl text-red-700 text-xs sm:text-sm mb-3">
Ошибка связи с сервером.
</div>
`;
chatWindow.insertAdjacentHTML("beforeend", errorHtml);
chatWindow.scrollTop = chatWindow.scrollHeight;
} finally {
if (sendBtn) {
sendBtn.disabled = false;
sendBtn.classList.remove("opacity-50");
}
}
}
function escapeHtml(text) {
if (!text) return "";
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
document.addEventListener("DOMContentLoaded", () => {
const input = document.getElementById("user-input");
const dropZone = document.getElementById("chat-window")?.parentElement;
const dropOverlay = document.getElementById("drop-overlay");
if (input) {
let historyIndex = -1;
let localHistory = JSON.parse(localStorage.getItem("scud_chat_input_history") || "[]");
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const text = input.value.trim();
if (text) {
if (localHistory.length === 0 || localHistory[0] !== text) {
localHistory.unshift(text);
if (localHistory.length > 50) localHistory.pop();
localStorage.setItem("scud_chat_input_history", JSON.stringify(localHistory));
}
historyIndex = -1;
}
sendMessage(e);
updateInputHeight(input);
return;
}
if (e.key === "ArrowUp") {
const textBeforeCursor = input.value.substring(0, input.selectionStart);
const isFirstLine = !textBeforeCursor.includes("\n");
if (isFirstLine && input.selectionStart === 0 && localHistory.length > 0) {
if (historyIndex < localHistory.length - 1) {
e.preventDefault();
if (historyIndex === -1) {
input.dataset.draft = input.value;
}
historyIndex++;
input.value = localHistory[historyIndex];
updateInputHeight(input);
input.setSelectionRange(input.value.length, input.value.length);
}
}
}
if (e.key === "ArrowDown") {
const textAfterCursor = input.value.substring(input.selectionEnd);
const isLastLine = !textAfterCursor.includes("\n");
if (isLastLine && input.selectionEnd === input.value.length && historyIndex >= 0) {
e.preventDefault();
if (historyIndex > 0) {
historyIndex--;
input.value = localHistory[historyIndex];
} else {
historyIndex = -1;
input.value = input.dataset.draft || "";
}
updateInputHeight(input);
input.setSelectionRange(input.value.length, input.value.length);
}
}
});
}
if (dropZone && dropOverlay) {
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
}, false);
});
["dragenter", "dragover"].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropOverlay.classList.remove("hidden");
dropOverlay.classList.add("flex");
}, false);
});
["dragleave", "drop"].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
if (eventName === "drop" || e.target === dropZone || !dropZone.contains(e.relatedTarget)) {
dropOverlay.classList.add("hidden");
dropOverlay.classList.remove("flex");
}
}, false);
});
dropZone.addEventListener("drop", (e) => {
const dt = e.dataTransfer;
const files = dt.files;
if (files && files.length > 0) {
const file = files[0];
handleFileSelect({ target: { files: [file] } });
const fileInput = document.getElementById("file-input");
if (fileInput) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
fileInput.files = dataTransfer.files;
}
}
}, false);
}
});
+581
View File
@@ -0,0 +1,581 @@
/**
* ===============================================================================
* 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 = `
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 mb-2 bg-indigo-700/80 rounded-lg text-xs font-semibold text-white shadow-xs">
<i class="fa-solid fa-paperclip text-xs"></i>
<span class="truncate max-w-xs">${escapeHtml(filename)}</span>
</div>
`;
}
const msgHtml = `
<div id="${msgId}" class="user-chat-bubble relative flex gap-3 max-w-4xl mx-auto w-full justify-end pt-3 scroll-mt-4">
<div class="flex-1 max-w-2xl bg-indigo-600 text-white rounded-2xl rounded-tr-none p-4 shadow-sm">
${fileBadge}
<div class="text-sm leading-relaxed whitespace-pre-wrap">${escapeHtml(text)}</div>
</div>
<div class="w-8 h-8 rounded-lg bg-slate-200 text-slate-600 flex items-center justify-center shrink-0 shadow-sm mt-0.5 font-bold text-sm">
<i class="fa-solid fa-user"></i>
</div>
</div>
`;
container.insertAdjacentHTML("beforeend", msgHtml);
}
function appendAssistantLoading() {
const container = document.getElementById("chat-messages-container");
if (!container) return null;
const loadingId = 'loading-' + Date.now();
const html = `
<div id="${loadingId}" class="flex gap-3 max-w-4xl mx-auto w-full pt-1">
<div class="w-8 h-8 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
<i class="fa-solid fa-robot text-sm"></i>
</div>
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
<div class="text-sm text-slate-500 flex items-center gap-2">
<i class="fa-solid fa-spinner fa-spin text-indigo-600"></i>
<span>ИИ обрабатывает запрос...</span>
</div>
</div>
</div>
`;
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 = `
<div class="mt-3 p-3.5 bg-indigo-50/80 border border-indigo-200 rounded-xl flex items-center justify-between gap-3 shadow-sm">
<div class="flex items-center gap-3 min-w-0">
<div class="w-9 h-9 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm">
<i class="fa-solid fa-file-lines text-base"></i>
</div>
<div class="min-w-0">
<div class="text-sm font-bold text-slate-800 truncate">${escapeHtml(fName)}</div>
<div class="text-xs text-slate-500">Задач выгружено: ${count} шт. · Markdown</div>
</div>
</div>
<a href="${dlUrl}" download="${escapeHtml(fName)}" target="_blank"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center gap-1.5 shrink-0">
<i class="fa-solid fa-download text-xs"></i>
<span>Скачать файл</span>
</a>
</div>
`;
} 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) => `
<tr class="inspect-row border-b border-slate-100 text-xs ${r.is_present ? 'bg-white' : 'bg-slate-50/60'} hover:bg-indigo-50/40"
data-fio="${escapeHtml(r.fio).toLowerCase()}" data-dept="${escapeHtml(r.department).toLowerCase()}">
<td class="p-2.5 text-slate-400 text-center font-mono w-10">${idx + 1}</td>
<td class="p-2.5 font-medium text-slate-800">${escapeHtml(r.fio)}</td>
<td class="p-2.5 text-slate-500 text-center">${escapeHtml(r.department)}</td>
<td class="p-2.5 text-center ${r.time_in !== 'Нет входа' ? 'font-bold text-emerald-700' : 'text-slate-400'}">${escapeHtml(r.time_in)}</td>
<td class="p-2.5 text-center text-slate-500">${escapeHtml(r.first_activity)}</td>
<td class="p-2.5 text-center ${r.time_out !== 'Нет выхода' ? 'font-bold text-slate-800' : 'text-slate-400'}">${escapeHtml(r.time_out)}</td>
<td class="p-2.5 text-center font-mono text-slate-600">${escapeHtml(r.in_building)}</td>
<td class="p-2.5 text-center font-bold">${r.is_present ? '<span class="text-emerald-600">✓ Да</span>' : '<span class="text-slate-400">Нет</span>'}</td>
</tr>
`).join('');
payloadHtml = `
<div class="mt-3 bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm flex flex-col">
<div class="px-4 py-3 bg-slate-100/90 border-b border-slate-200 flex items-center justify-between gap-2 flex-wrap">
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-slate-800">Срез #${escapeHtml(actionPayload.snapshot_id)}</span>
<span class="text-xs text-slate-500">· Всего: ${records.length} чел. (Присутствуют: ${actionPayload.present_count || 0})</span>
</div>
<div class="flex items-center gap-2">
<input type="text" id="${searchInputId}" placeholder="Поиск в срезе (ФИО / отдел)..."
oninput="window.filterInspectTable('${inspectTableId}', this.value)"
class="text-xs px-3 py-1.5 bg-white border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 w-60" />
<button onclick="window.sendChatAction('покажи срезы')" class="text-xs text-indigo-600 hover:underline font-semibold">Все срезы</button>
</div>
</div>
<div class="max-h-96 overflow-y-auto">
<table id="${inspectTableId}" class="w-full text-left border-collapse">
<thead class="bg-slate-50 text-[11px] uppercase text-slate-500 sticky top-0 border-b border-slate-200 shadow-xs">
<tr>
<th class="p-2.5 text-center w-10">№</th>
<th class="p-2.5">Сотрудник</th>
<th class="p-2.5 text-center">Отдел</th>
<th class="p-2.5 text-center">Вход</th>
<th class="p-2.5 text-center">Активность</th>
<th class="p-2.5 text-center">Выход</th>
<th class="p-2.5 text-center">В здании</th>
<th class="p-2.5 text-center">Статус</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
</div>
</div>
`;
} else if (actionPayload.type === 'PROMPT_EDITOR') {
const draftText = actionPayload.raw_draft || actionPayload.baseline_prompt || '';
const editorId = 'prompt-editor-' + Date.now();
payloadHtml = `
<div class="mt-3 p-3.5 bg-slate-50 border border-slate-300 rounded-xl flex flex-col gap-2 shadow-inner">
<div class="flex items-center justify-between text-xs font-bold text-slate-700">
<span><i class="fa-solid fa-pen-to-square text-indigo-600 mr-1"></i> Инлайн-редактор системного промпта:</span>
<span class="text-[11px] text-slate-400 font-normal">Прямое редактирование текста</span>
</div>
<textarea id="${editorId}" rows="14"
class="w-full text-xs font-mono p-3 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-white leading-relaxed resize-y">${escapeHtml(draftText)}</textarea>
<div class="flex items-center justify-end gap-2 pt-1">
<button type="button" onclick="window.sendChatAction('отмена')" class="px-3.5 py-1.5 text-xs text-slate-600 hover:bg-slate-200 rounded-lg transition font-medium">Отменить</button>
<button type="button" onclick="window.submitPromptDraftToDiff('${editorId}')" class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition flex items-center gap-1.5">
<i class="fa-solid fa-eye text-xs"></i>
<span>Показать превью изменений</span>
</button>
</div>
</div>
`;
} else if (actionPayload.type === 'RULES_EDITOR') {
const draftText = actionPayload.raw_draft || actionPayload.baseline_prompt || '';
const editorId = 'rules-editor-' + Date.now();
payloadHtml = `
<div class="mt-3 p-3.5 bg-slate-50 border border-slate-300 rounded-xl flex flex-col gap-2 shadow-inner">
<div class="flex items-center justify-between text-xs font-bold text-slate-700">
<span><i class="fa-solid fa-book-bookmark text-emerald-600 mr-1"></i> Редактор базы знаний и правил компании:</span>
<span class="text-[11px] text-slate-400 font-normal">Прямое изменение правил кадрового арбитража</span>
</div>
<textarea id="${editorId}" rows="12"
class="w-full text-xs font-mono p-3 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-white leading-relaxed resize-y">${escapeHtml(draftText)}</textarea>
<div class="flex items-center justify-end gap-2 pt-1">
<button type="button" onclick="window.sendChatAction('отмена')" class="px-3.5 py-1.5 text-xs text-slate-600 hover:bg-slate-200 rounded-lg transition font-medium">Отменить</button>
<button type="button" onclick="window.submitRulesDraftToDiff('${editorId}')" class="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow transition flex items-center gap-1.5">
<i class="fa-solid fa-eye text-xs"></i>
<span>Показать превью изменений</span>
</button>
</div>
</div>
`;
} 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 = `
<div class="flex flex-wrap gap-2 mt-3.5 pt-2.5 border-t border-slate-100">
${allButtons.map(b => `
<button onclick="window.sendChatAction('${escapeHtml(b.value || b.action || b.title || '')}')"
class="px-3 py-1.5 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 rounded-lg text-xs font-semibold border border-indigo-200 transition">
${escapeHtml(b.label || b.title || b.action)}
</button>
`).join('')}
</div>
`;
}
}
const bodyContent = isHtmlBody ? text : escapeHtml(text);
const html = `
<div class="flex gap-3 max-w-4xl mx-auto w-full pt-1">
<div class="w-8 h-8 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
<i class="fa-solid fa-robot text-sm"></i>
</div>
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm min-w-0">
<div class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1.5">ИИ-ассистент SCUD Orion AI</div>
<div class="text-sm text-slate-800 leading-relaxed whitespace-pre-wrap">${bodyContent}</div>
${payloadHtml}
${buttonsHtml}
</div>
</div>
`;
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);
});
@@ -0,0 +1,445 @@
/*
===============================================================================
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 `
<div class="p-6 bg-slate-50 border border-slate-200 rounded-xl text-center text-xs text-slate-500 my-2">
📸 За дату <b>${queryDate}</b> сохраненных снапшотов не найдено.
</div>
`;
}
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 `
<div class="p-2.5 bg-purple-50/60 rounded-lg border border-purple-200 shadow-sm flex items-center justify-between gap-3">
<div class="flex items-center gap-2.5 min-w-0">
<span class="w-4 flex justify-center text-purple-400" title="Итоговый срез защищен">
<i class="fa-solid fa-lock text-[11px]"></i>
</span>
<span class="font-mono text-xs font-bold px-2 py-0.5 rounded bg-purple-100 text-purple-800 border border-purple-300">
#${snapId}
</span>
<div class="flex items-center gap-3 text-xs text-slate-600">
<span class="flex items-center gap-1 font-semibold text-purple-900">
<i class="fa-regular fa-clock text-purple-600 text-[11px]"></i> ${snapTime}
</span>
<span class="flex items-center gap-1 text-slate-500">
<i class="fa-solid fa-users text-slate-400 text-[11px]"></i> ${count} записей
</span>
</div>
</div>
<span class="text-[10px] font-bold text-purple-700 bg-purple-100 border border-purple-200 px-2 py-0.5 rounded">
Итоговый Y-срез
</span>
</div>
`;
}
return `
<div class="p-2.5 bg-white rounded-lg border border-slate-200 shadow-sm hover:border-slate-300 transition flex items-center justify-between gap-3">
<div class="flex items-center gap-2.5 min-w-0">
<input type="checkbox" value="${snapId}" onchange="window.updateSelectedSnapshots(this)"
class="snapshot-item-checkbox rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 w-4 h-4 cursor-pointer">
<span class="font-mono text-xs font-bold px-2 py-0.5 rounded bg-slate-100 text-slate-700 border border-slate-200">
#${snapId}
</span>
<div class="flex items-center gap-3 text-xs text-slate-600">
<span class="flex items-center gap-1 font-semibold text-slate-800">
<i class="fa-regular fa-clock text-indigo-500 text-[11px]"></i> ${snapTime}
</span>
<span class="flex items-center gap-1 text-slate-500">
<i class="fa-solid fa-users text-slate-400 text-[11px]"></i> ${count} записей
</span>
</div>
</div>
<button type="button" onclick="window.sendChatAction('удали снапшот ${snapId}')"
class="p-1 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded border border-transparent hover:border-rose-200 transition"
title="Удалить срез">
<i class="fa-solid fa-trash-can text-xs"></i>
</button>
</div>
`;
}).join('');
return `
<div class="snapshots-widget-root w-full max-w-4xl mx-auto my-2 bg-slate-50 border border-slate-300 rounded-xl shadow-md overflow-hidden flex flex-col">
<div class="px-4 py-2.5 bg-white border-b border-slate-200 flex items-center justify-between gap-2 flex-wrap">
<div class="flex items-center gap-2">
<div class="bg-indigo-600 text-white p-1.5 rounded-lg flex items-center justify-center">
<i class="fa-solid fa-camera text-xs"></i>
</div>
<div>
<span class="text-xs font-bold text-slate-800">Реестр срезов СКУД</span>
<span class="text-xs text-slate-500 ml-1">за ${queryDate}</span>
</div>
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer select-none">
<input type="checkbox" onchange="window.toggleSelectAllSnapshots(this)" class="select-all-snapshots-cb rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 w-3.5 h-3.5">
<span>Выбрать все</span>
</label>
<span class="text-[11px] font-semibold bg-indigo-50 text-indigo-700 border border-indigo-200 px-2 py-0.5 rounded-full">
Срезов: ${snapshots.length}
</span>
</div>
</div>
<div class="p-3 flex flex-col gap-2">
${rowsHtml}
</div>
<!-- ПОДВАЛ С КНОПКОЙ УДАЛЕНИЯ ВЫБРАННЫХ -->
<div class="snapshot-bulk-actions-footer hidden px-4 py-2 bg-rose-50/70 border-t border-rose-200 flex items-center justify-between">
<span class="text-xs text-rose-800 font-medium bulk-selected-counter">Выбрано: 0</span>
<button type="button" onclick="window.submitBulkDeleteSnapshots(this)"
class="px-3 py-1.5 bg-rose-600 hover:bg-rose-700 active:bg-rose-800 text-white font-bold rounded-lg text-xs flex items-center gap-1.5 transition shadow-sm">
<i class="fa-solid fa-trash-can"></i>
<span>Удалить выбранные</span>
</button>
</div>
</div>
`;
}
// ⭐️ Обработчики чекбоксов
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 '<span class="text-[10px] px-2 py-0.5 rounded font-bold bg-rose-100 text-rose-700 border border-rose-200">🔥 HIGH</span>';
if (p === 'LOW') return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-slate-100 text-slate-600 border border-slate-200">☕ LOW</span>';
return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-amber-100 text-amber-700 border border-amber-200">⚡ MEDIUM</span>';
};
const getStatusBadge = (status) => {
const s = (status || 'BACKLOG').toUpperCase();
if (s === 'IN_PROGRESS' || s === 'PROGRESS') return '<span class="text-[10px] px-2 py-0.5 rounded font-bold bg-blue-50 text-blue-700 border border-blue-200">⚙️ В работе</span>';
if (s === 'COMPLETED' || s === 'DONE') return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">✓ Готово</span>';
return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-slate-50 text-slate-600 border border-slate-200">📋 В планах</span>';
};
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
? `<button type="button" onclick="window.sendChatAction('заверши задачу ${id}')" class="px-2.5 py-1 text-xs font-semibold rounded bg-emerald-50 text-emerald-700 border border-emerald-300 hover:bg-emerald-100 transition-colors shadow-sm" title="Завершить задачу">✓ Готово</button>`
: (!isDone
? `<button type="button" onclick="window.sendChatAction('возьми в работу задачу ${id}')" class="px-2.5 py-1 text-xs font-semibold rounded bg-blue-50 text-blue-700 border border-blue-300 hover:bg-blue-100 transition-colors shadow-sm" title="Взять в работу">⚙️ В работу</button>`
: '');
return `
<div id="task-card-${id}" class="p-3 bg-white rounded-lg border border-slate-200 shadow-sm hover:border-slate-300 transition-all flex flex-col gap-2">
<div class="task-view-mode flex items-center justify-between gap-3">
<div class="flex items-center gap-2 flex-wrap flex-1 min-w-0">
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-slate-100 text-slate-700 border border-slate-200">#${id}</span>
<span class="text-sm font-medium text-slate-900 truncate" title="${title}">${title}</span>
</div>
<div class="flex items-center gap-1.5 shrink-0">
${actionBtn}
<button type="button" onclick="window.openTaskInlineEditor(${id})" class="p-1 text-xs text-slate-500 hover:text-indigo-600 hover:bg-slate-50 rounded border border-slate-200 transition-colors" title="Редактировать">✏️</button>
<button type="button" onclick="window.sendChatAction('удали задачу ${id}')" class="p-1 text-xs text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded border border-slate-200 transition-colors" title="Удалить">🗑️</button>
</div>
</div>
<div class="task-view-mode flex items-center gap-2 text-xs text-slate-500 flex-wrap">
${getPrioBadge(t.priority)}
${getStatusBadge(t.status)}
<span class="px-1.5 py-0.5 rounded bg-slate-50 border border-slate-200 text-slate-600 font-mono text-[11px]">${t.module || 'general'}</span>
${t.due_date ? `<span class="text-slate-500">📅 срок: <b>${t.due_date}</b></span>` : ''}
${t.created_at ? `<span class="text-slate-400">создана: ${t.created_at.split(' ')[0]}</span>` : ''}
</div>
<!-- ФОРМА ИНЛАЙН РЕДАКТИРОВАНИЯ -->
<div id="task-editor-${id}" class="hidden flex flex-col gap-2 pt-2 border-t border-slate-100">
<input type="text" id="task-edit-title-${id}" value="${title.replace(/"/g, '&quot;')}" class="w-full text-xs px-2.5 py-1.5 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50" placeholder="Описание задачи..." />
<div class="flex items-center gap-2 flex-wrap">
<input type="date" id="task-edit-date-${id}" value="${t.due_date || ''}" class="text-xs px-2 py-1 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50" />
<select id="task-edit-prio-${id}" class="text-xs px-2 py-1 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50">
<option value="LOW" ${t.priority === 'LOW' ? 'selected' : ''}>☕ LOW</option>
<option value="MEDIUM" ${t.priority === 'MEDIUM' || !t.priority ? 'selected' : ''}>⚡ MEDIUM</option>
<option value="HIGH" ${t.priority === 'HIGH' ? 'selected' : ''}>🔥 HIGH</option>
<option value="CRITICAL" ${t.priority === 'CRITICAL' ? 'selected' : ''}>🚨 CRITICAL</option>
</select>
<button type="button" onclick="window.saveTaskInlineEdit(${id})" class="px-2.5 py-1 text-xs font-semibold rounded bg-indigo-600 text-white hover:bg-indigo-700 transition-colors shadow-sm">Сохранить</button>
<button type="button" onclick="window.closeTaskInlineEditor(${id})" class="px-2 py-1 text-xs font-medium rounded text-slate-500 hover:bg-slate-100 transition-colors">Отмена</button>
</div>
</div>
</div>
`;
}).join('');
const emptyState = `
<div class="p-8 text-center text-slate-400">
<div class="text-3xl mb-2">📭</div>
<div class="text-sm font-medium">Нет задач в категории «${currentFilter}»</div>
</div>
`;
return `
<div class="task-widget-root w-full max-w-4xl mx-auto my-2 bg-slate-50 border border-slate-300 rounded-xl shadow-md overflow-hidden flex flex-col">
<div class="px-4 py-3 bg-white border-b border-slate-200 flex items-center justify-between gap-2 flex-wrap">
<div class="flex items-center gap-1.5 flex-wrap">
<button type="button" onclick="window.switchTaskFilter('IN_PROGRESS', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'IN_PROGRESS' ? 'bg-blue-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
⚙️ В работе (${counts.IN_PROGRESS})
</button>
<button type="button" onclick="window.switchTaskFilter('PLANNED', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'PLANNED' ? 'bg-indigo-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
📋 В планах (${counts.PLANNED})
</button>
<button type="button" onclick="window.switchTaskFilter('COMPLETED', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'COMPLETED' ? 'bg-emerald-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
✓ Готово (${counts.COMPLETED})
</button>
<button type="button" onclick="window.switchTaskFilter('ALL', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'ALL' ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
Все (${counts.ALL})
</button>
</div>
<button type="button" onclick="window.toggleCreateTaskForm(this)" class="px-3 py-1.5 text-xs font-bold rounded-md bg-indigo-600 hover:bg-indigo-700 text-white transition-all shadow-sm flex items-center gap-1">
➕ Добавить задачу
</button>
</div>
<div class="new-task-creation-form hidden p-3 bg-indigo-50/70 border-b border-indigo-100 flex flex-col gap-2">
<div class="text-xs font-bold text-indigo-900">Новая задача:</div>
<input type="text" class="new-task-title w-full text-xs px-2.5 py-1.5 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white" placeholder="Что необходимо сделать?..." />
<div class="flex items-center gap-2 flex-wrap">
<input type="date" class="new-task-date text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white" />
<select class="new-task-prio text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white">
<option value="LOW">☕ LOW (Низкий)</option>
<option value="MEDIUM" selected>⚡ MEDIUM (Средний)</option>
<option value="HIGH">🔥 HIGH (Высокий)</option>
<option value="CRITICAL">🚨 CRITICAL (Критический)</option>
</select>
<select class="new-task-status text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white">
<option value="BACKLOG" selected>📋 В планы (Бэклог)</option>
<option value="IN_PROGRESS">⚙️ Сразу в работу</option>
</select>
<button type="button" onclick="window.submitCreateTask(this)" class="px-3 py-1 text-xs font-bold rounded bg-indigo-600 text-white hover:bg-indigo-700 transition-colors shadow-sm">Создать</button>
<button type="button" onclick="window.toggleCreateTaskForm(this)" class="px-2 py-1 text-xs font-medium rounded text-slate-500 hover:bg-slate-200 transition-colors">Отмена</button>
</div>
</div>
<div class="p-3 overflow-y-auto max-h-[70vh] flex flex-col gap-2 task-rows-container">
${filteredTasks.length > 0 ? taskRows : emptyState}
</div>
</div>
`;
}
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('Сетевая ошибка при обновлении задачи');
}
};
@@ -0,0 +1,231 @@
/**
* ===============================================================================
* FILE: modules/web_api/static/js/manual_absences.js
* ROLE: Модальные окна "Мест. командир.", "Иное", живой автокомплит ФИО из 1С:ЗУП
* и мгновенная синхронизация с боковой панелью 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);
}
}
function openManualAbsenceModal(type) {
activeAbsenceType = type;
const isTrip = type === 'LOCAL_TRIP';
const titleEl = document.getElementById('manual-absence-modal-title');
const reasonBlock = document.getElementById('manual-absence-reason-block');
const reasonSelect = document.getElementById('manual-absence-reason-select');
if (titleEl) {
titleEl.innerHTML = isTrip
? '<i class="fa-solid fa-location-dot text-indigo-600 mr-2"></i>Местная командировка'
: '<i class="fa-solid fa-clipboard-list text-purple-600 mr-2"></i>Иные причины отсутствия';
}
if (reasonBlock && reasonSelect) {
if (isTrip) {
reasonBlock.classList.add('hidden');
} else {
reasonBlock.classList.remove('hidden');
reasonSelect.innerHTML = reasonsCache.map(r => `<option value="${r}">${r}</option>`).join('');
}
}
// Сброс полей ввода
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 suggestionsBox = document.getElementById('manual-absence-suggestions');
if (fioInput) fioInput.value = '';
if (deptInput) deptInput.value = '';
if (posInput) posInput.value = '';
if (startDateInput) startDateInput.value = '';
if (suggestionsBox) {
suggestionsBox.classList.add('hidden');
suggestionsBox.innerHTML = '';
}
// Окончание по умолчанию — сегодняшний день
if (endDateInput) {
const today = new Date().toISOString().split('T')[0];
endDateInput.value = today;
}
loadManualAbsencesTable();
const modal = document.getElementById('manual-absence-modal');
if (modal) modal.classList.remove('hidden');
}
function closeManualAbsenceModal() {
const modal = document.getElementById('manual-absence-modal');
if (modal) modal.classList.add('hidden');
}
// Живой автокомплит ФИО из базы zup_staff
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 => `
<div class="p-2 hover:bg-indigo-50 cursor-pointer border-b border-slate-100 flex flex-col text-xs"
onclick="selectStaffSuggestion('${escapeHtml(it.fio)}', '${escapeHtml(it.department)}', '${escapeHtml(it.position)}')">
<span class="font-bold text-slate-800">${escapeHtml(it.fio)}</span>
<span class="text-[10px] text-slate-500">${escapeHtml(it.department)} · ${escapeHtml(it.position)}</span>
</div>
`).join('');
box.classList.remove('hidden');
} catch (e) {
console.error(e);
}
}, 200);
});
}
function selectStaffSuggestion(fio, dept, pos) {
const fioInput = document.getElementById('manual-absence-fio-input');
const deptInput = document.getElementById('manual-absence-dept');
const posInput = document.getElementById('manual-absence-pos');
const box = document.getElementById('manual-absence-suggestions');
if (fioInput) fioInput.value = fio;
if (deptInput) deptInput.value = dept;
if (posInput) posInput.value = pos;
if (box) box.classList.add('hidden');
}
async function submitManualAbsence() {
const fioInput = document.getElementById('manual-absence-fio-input');
const fio = fioInput ? fioInput.value.trim() : '';
if (!fio) {
alert('Укажите ФИО сотрудника');
return;
}
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 payload = {
absence_type: activeAbsenceType,
fio: fio,
department: deptVal,
position: posVal,
date_start: startDateVal,
date_end: endDateVal,
reason: activeAbsenceType === 'LOCAL_TRIP' ? 'Местная командировка' : (reasonSelect ? reasonSelect.value : 'Иное')
};
try {
const res = await fetch('/api/v1/manual-absences/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
if (fioInput) fioInput.value = '';
loadManualAbsencesTable();
// Обновляем список карточек в боковой панели и закрываем окно
if (window.SidebarManager && typeof SidebarManager.renderContent === 'function') {
SidebarManager.renderContent();
}
closeManualAbsenceModal();
} else {
alert('Ошибка добавления записи');
}
} catch (e) {
alert('Сетевая ошибка при добавлении');
}
}
async function loadManualAbsencesTable() {
const tableContainer = document.getElementById('manual-absences-table-body');
if (!tableContainer) return;
try {
const res = await fetch(`/api/v1/manual-absences/?type=${activeAbsenceType}`);
if (!res.ok) return;
const data = await res.json();
const items = data.items || [];
if (items.length === 0) {
tableContainer.innerHTML = '<tr><td colspan="5" class="text-center p-4 text-xs text-slate-400">Нет активных записей</td></tr>';
return;
}
tableContainer.innerHTML = items.map(it => `
<tr class="border-b border-slate-100 text-xs hover:bg-slate-50">
<td class="p-2 font-bold text-slate-800">${escapeHtml(it.fio)}</td>
<td class="p-2 text-slate-500">${escapeHtml(it.department || '—')}</td>
<td class="p-2 text-slate-600">${escapeHtml(it.reason)}</td>
<td class="p-2 text-center text-slate-500 font-mono text-[11px]">${it.date_start || '—'} / ${it.date_end || '—'}</td>
<td class="p-2 text-center">
<button onclick="deleteManualAbsenceRecord(${it.id})" class="text-slate-400 hover:text-rose-600 transition p-1" title="Удалить">
<i class="fa-solid fa-trash-can"></i>
</button>
</td>
</tr>
`).join('');
} catch (e) {
console.error(e);
}
}
async function deleteManualAbsenceRecord(id) {
if (!confirm('Удалить эту запись?')) return;
try {
const res = await fetch(`/api/v1/manual-absences/${id}`, { method: 'DELETE' });
if (res.ok) {
loadManualAbsencesTable();
if (window.SidebarManager && typeof SidebarManager.renderContent === 'function') {
SidebarManager.renderContent();
}
}
} catch (e) {
alert('Ошибка при удалении');
}
}
document.addEventListener('DOMContentLoaded', () => {
loadAbsenceReasons();
setupStaffAutocomplete(
document.getElementById('manual-absence-fio-input'),
'manual-absence-suggestions'
);
});
+673
View File
@@ -0,0 +1,673 @@
/**
* ===============================================================================
* FILE: modules/web_api/static/js/sidebar.js
* ROLE: Контроллер левого сайдбара с 5-хабовой навигацией, реестрами (2x2),
* модальным окном добавления исключений с автокомплитом из 1С:ЗУП.
* ===============================================================================
*/
window.escapeHtml = function(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
window.SidebarManager = {
currentHub: 'TASKS',
currentSubTab: {
'REGISTRIES': 'REMOTE'
},
hubs: [
{ id: 'TASKS', label: 'Задачи', icon: 'fa-list-check' },
{ id: 'SNAPSHOTS', label: 'Срезы', icon: 'fa-camera' },
{ id: 'REGISTRIES', label: 'Реестры', icon: 'fa-address-book' },
{ id: 'PROMPT', label: 'Промпт', icon: 'fa-terminal' },
{ id: 'CONTEXT', label: 'Контекст', icon: 'fa-comments' }
],
// Сетка реестров 2x2
subTabs: {
'REGISTRIES': [
{ id: 'REMOTE', label: 'Удаленщики', icon: 'fa-house-laptop' },
{ id: 'EXCEPTIONS', label: 'Исключения', icon: 'fa-user-shield' },
{ id: 'LOCAL_TRIP', label: 'Мест. командир.', icon: 'fa-location-dot' },
{ id: 'OTHER', label: 'Иное', icon: 'fa-clipboard-list' }
]
},
init() {
this.renderHeader();
this.renderContent();
},
setHub(hubId) {
this.currentHub = hubId;
this.renderHeader();
this.renderContent();
},
setSubTab(subTabId) {
this.currentSubTab[this.currentHub] = subTabId;
this.renderHeader();
this.renderContent();
},
renderHeader() {
const headerContainer = document.getElementById("sidebar-dynamic-header");
if (!headerContainer) return;
// 1. Основные 5 Хабов
const hubsHtml = `
<div class="flex items-center border-b border-slate-200 bg-slate-50/80 px-1 pt-1.5 overflow-x-auto gap-0.5">
${this.hubs.map(h => {
const isActive = this.currentHub === h.id;
return `
<button onclick="SidebarManager.setHub('${h.id}')"
class="flex-1 py-1.5 px-1 flex flex-col items-center gap-1 border-b-2 font-bold text-[10px] transition ${
isActive
? 'border-indigo-600 text-indigo-600 bg-white rounded-t-lg shadow-sm'
: 'border-transparent text-slate-500 hover:text-slate-800 hover:bg-slate-100/60 rounded-t-lg'
}">
<i class="fa-solid ${h.icon} text-xs"></i>
<span class="truncate">${h.label}</span>
</button>
`;
}).join('')}
</div>
`;
// 2. Подвкладки реестров (Сетка 2x2)
let subTabsHtml = '';
if (this.subTabs[this.currentHub]) {
const currentActiveSub = this.currentSubTab[this.currentHub] || this.subTabs[this.currentHub][0].id;
subTabsHtml = `
<div class="grid grid-cols-2 gap-1.5 p-1.5 bg-slate-100/90 border-b border-slate-200">
${this.subTabs[this.currentHub].map(st => {
const isSubActive = currentActiveSub === st.id;
return `
<button onclick="SidebarManager.setSubTab('${st.id}')"
class="py-1 px-2 rounded-md text-[11px] font-semibold flex items-center justify-center gap-1.5 transition ${
isSubActive
? 'bg-white text-indigo-700 shadow-sm'
: 'text-slate-600 hover:text-slate-900 hover:bg-white/50'
}">
<i class="fa-solid ${st.icon} text-[10px]"></i>
<span>${st.label}</span>
</button>
`;
}).join('')}
</div>
`;
}
headerContainer.innerHTML = hubsHtml + subTabsHtml;
},
renderContent() {
const contentContainer = document.getElementById("sidebar-dynamic-content");
if (!contentContainer) return;
contentContainer.scrollTop = 0;
switch (this.currentHub) {
case 'TASKS':
this.renderTasksView(contentContainer);
break;
case 'SNAPSHOTS':
this.renderSnapshotsView(contentContainer);
break;
case 'REGISTRIES':
this.renderRegistriesView(contentContainer);
break;
case 'PROMPT':
this.renderPromptView(contentContainer);
break;
case 'CONTEXT':
this.renderContextView(contentContainer);
break;
default:
contentContainer.innerHTML = `<div class="p-4 text-xs text-slate-400 text-center">Раздел в разработке</div>`;
}
},
// =========================================================================
// ХАБ 1: ЗАДАЧИ
// =========================================================================
renderTasksView(container) {
container.innerHTML = `
<div id="tasks-list-container" class="flex-1 flex flex-col gap-2">
<div class="text-center py-10 text-xs text-slate-400">
<i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка задач...
</div>
</div>
`;
if (window.loadTasks) {
window.loadTasks();
}
},
// =========================================================================
// ХАБ 2: СРЕЗЫ СКУД
// =========================================================================
async renderSnapshotsView(container) {
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка срезов...</div>`;
try {
const res = await fetch("/api/v1/snapshots", { headers: AuthManager.getAuthHeaders() });
const data = res.ok ? await res.json() : { snapshots: [] };
const snaps = data.snapshots || [];
if (snaps.length === 0) {
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400">Срезы СКУД не найдены</div>`;
return;
}
const itemsHtml = snaps.map(s => `
<div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-indigo-300 transition">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="font-bold text-slate-800">${escapeHtml(s.snapshot_id)}</span>
${s.is_final ? '<span class="px-1.5 py-0.2 rounded text-[9px] font-bold bg-amber-50 text-amber-700 border border-amber-200">Финал Y</span>' : ''}
</div>
<div class="text-[10px] text-slate-400 mt-0.5">${escapeHtml(s.snapshot_time)} · ${s.record_count || 0} зап.</div>
</div>
<button onclick="window.sendChatAction('покажи срез ${escapeHtml(s.snapshot_id)}')" class="px-2 py-1 bg-slate-100 hover:bg-indigo-50 text-slate-600 hover:text-indigo-600 rounded-lg text-[10px] font-semibold transition" title="Открыть в чате">
Инспекция
</button>
</div>
`).join('');
container.innerHTML = `
<div class="p-2 flex flex-col gap-2">
<div class="flex items-center justify-between px-1">
<span class="text-xs font-bold text-slate-700">Всего срезов: ${snaps.length}</span>
<button onclick="SidebarManager.renderSnapshotsView(document.getElementById('sidebar-dynamic-content'))" class="text-slate-400 hover:text-indigo-600 p-1" title="Обновить">
<i class="fa-solid fa-arrows-rotate text-xs"></i>
</button>
</div>
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">${itemsHtml}</div>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки срезов</div>`;
}
},
// =========================================================================
// ХАБ 3: РЕЕСТРЫ (УДАЛЕНЩИКИ + ИСКЛЮЧЕНИЯ + МЕСТ. КОМАНДИР. + ИНОЕ)
// =========================================================================
renderRegistriesView(container) {
const subTab = this.currentSubTab['REGISTRIES'] || 'REMOTE';
if (subTab === 'REMOTE') {
this.renderRemoteWorkersView(container);
} else if (subTab === 'EXCEPTIONS') {
this.renderExceptionsView(container);
} else {
this.renderManualAbsencesView(container, subTab);
}
},
async renderRemoteWorkersView(container) {
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка удаленщиков...</div>`;
try {
const res = await fetch("/api/v1/remote-workers", { headers: AuthManager.getAuthHeaders() });
const data = res.ok ? await res.json() : { workers: [] };
const workers = data.workers || [];
const listHtml = workers.map(w => {
const dFrom = w.date_from ? w.date_from : 'сегодня';
const dTo = w.date_to ? w.date_to : 'бессрочно';
const periodLabel = (!w.date_to) ? `с ${dFrom} (бессрочно)` : `${dFrom} — ${dTo}`;
return `
<div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-emerald-300 transition">
<div class="min-w-0 flex-1">
<div class="font-bold text-slate-800 truncate">${escapeHtml(w.fio)}</div>
<div class="text-[10px] text-slate-400 truncate">${escapeHtml(w.department || 'Все')}</div>
<div class="mt-0.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">
<i class="fa-regular fa-calendar-days text-[8px]"></i>
<span>${escapeHtml(periodLabel)}</span>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0">
<button onclick="openRemoteWorkerModal('EDIT', '${escapeHtml(w.fio)}', '${escapeHtml(w.department || 'Все')}', '${escapeHtml(w.date_from || '')}', '${escapeHtml(w.date_to || '')}')"
class="text-slate-400 hover:text-emerald-600 p-1.5 rounded-lg hover:bg-emerald-50 transition"
title="Изменить сроки удаленки">
<i class="fa-solid fa-pen-to-square text-xs"></i>
</button>
<button onclick="SidebarManager.deleteRemoteWorker('${escapeHtml(w.fio)}')"
class="text-slate-400 hover:text-rose-600 p-1.5 rounded-lg hover:bg-rose-50 transition"
title="Удалить">
<i class="fa-solid fa-trash-can text-xs"></i>
</button>
</div>
</div>
`;
}).join('');
container.innerHTML = `
<div class="p-2 flex flex-col gap-2.5">
<div class="flex items-center justify-between px-1">
<span class="text-xs font-bold text-slate-700">В реестре: ${workers.length} чел.</span>
<button onclick="openRemoteWorkerModal('ADD')" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-[11px] font-bold shadow-sm flex items-center gap-1 transition">
<i class="fa-solid fa-plus text-[10px]"></i> Добавить
</button>
</div>
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">
${workers.length > 0 ? listHtml : '<div class="text-center py-8 text-xs text-slate-400">Список удаленщиков пуст</div>'}
</div>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки реестра удаленщиков</div>`;
}
},
async deleteRemoteWorker(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) {
this.renderContent();
} else {
alert("Ошибка удаления");
}
} catch (e) {
alert("Ошибка сети");
}
},
async renderExceptionsView(container) {
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка исключений...</div>`;
try {
const res = await fetch("/api/v1/exceptions/", { headers: AuthManager.getAuthHeaders() });
const data = res.ok ? await res.json() : {};
const categories = [
{ key: 'include_fio', title: 'Белый список (ФИО)' },
{ key: 'fio', title: 'Исключенные сотрудники (ФИО)' },
{ key: 'departments', title: 'Исключенные отделы' },
{ key: 'positions', title: 'Исключенные должности' },
{ key: 'turnstile_fio', title: 'Пр. турникет (ФИО)', badge: 'Оба турникета' },
{ key: 'turnstile_departments', title: 'Пр. турникет (Отделы)', badge: 'Оба турникета' }
];
const html = categories.map(cat => {
const items = data[cat.key] || [];
const isTurnstile = cat.key.startsWith('turnstile_');
const badgeHtml = cat.badge
? `<span class="px-1.5 py-0.2 rounded text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">${cat.badge}</span>`
: '';
return `
<div class="bg-white border ${isTurnstile ? 'border-emerald-200/80 bg-emerald-50/10' : 'border-slate-200'} rounded-xl p-3 flex flex-col gap-2 shadow-sm">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<span class="font-bold text-xs text-slate-700">${cat.title} (${items.length})</span>
${badgeHtml}
</div>
<button onclick="openExceptionModal('${cat.key}', '${cat.title}')" class="text-indigo-600 hover:text-indigo-800 text-xs font-bold">
+ Добавить
</button>
</div>
<div class="flex flex-wrap gap-1">
${items.map(it => `
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] ${isTurnstile ? 'bg-emerald-50 text-emerald-800 border border-emerald-200' : 'bg-slate-100 text-slate-700 border border-slate-200'}">
${escapeHtml(it)}
<button onclick="SidebarManager.deleteExceptionItem('${cat.key}', '${escapeHtml(it)}')" class="hover:text-rose-600 ml-0.5">×</button>
</span>
`).join('') || '<span class="text-[10px] text-slate-400">Пусто</span>'}
</div>
</div>
`;
}).join('');
container.innerHTML = `<div class="p-2 flex flex-col gap-2 max-h-[75vh] overflow-y-auto">${html}</div>`;
} catch (e) {
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки исключений</div>`;
}
},
async deleteExceptionItem(category, value) {
if (!confirm(`Удалить "${value}" из реестра?`)) return;
try {
const res = await fetch(`/api/v1/exceptions/?category=${encodeURIComponent(category)}&value=${encodeURIComponent(value)}`, {
method: "DELETE",
headers: AuthManager.getAuthHeaders()
});
if (res.ok) this.renderContent();
else alert("Ошибка удаления");
} catch (e) {
alert("Ошибка сети");
}
},
async renderManualAbsencesView(container, absenceType) {
const typeLabel = absenceType === 'LOCAL_TRIP' ? 'местных командировок' : 'иных отсутствий';
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка ${typeLabel}...</div>`;
try {
const res = await fetch(`/api/v1/manual-absences/?type=${absenceType}`);
const data = res.ok ? await res.json() : { items: [] };
const items = data.items || [];
const listHtml = items.map(it => {
const dFrom = it.date_start ? it.date_start : 'сегодня';
const dTo = it.date_end ? it.date_end : 'сегодня';
const periodLabel = (dFrom === dTo) ? `на ${dFrom}` : `${dFrom} — ${dTo}`;
const badgeText = absenceType === 'LOCAL_TRIP' ? 'Местная командировка' : escapeHtml(it.reason);
return `
<div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-indigo-300 transition">
<div class="min-w-0 flex-1">
<div class="font-bold text-slate-800 truncate">${escapeHtml(it.fio)}</div>
<div class="text-[10px] text-slate-400 truncate">${escapeHtml(it.department || 'Все')} · ${badgeText}</div>
<div class="mt-0.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-indigo-50 text-indigo-700 border border-indigo-200">
<i class="fa-regular fa-calendar-days text-[8px]"></i>
<span>${escapeHtml(periodLabel)}</span>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0">
<button onclick="SidebarManager.deleteManualAbsenceRecord(${it.id})"
class="text-slate-400 hover:text-rose-600 p-1.5 rounded-lg hover:bg-rose-50 transition"
title="Удалить">
<i class="fa-solid fa-trash-can text-xs"></i>
</button>
</div>
</div>
`;
}).join('');
container.innerHTML = `
<div class="p-2 flex flex-col gap-2.5">
<div class="flex items-center justify-between px-1">
<span class="text-xs font-bold text-slate-700">В реестре: ${items.length} чел.</span>
<button onclick="openManualAbsenceModal('${absenceType}')" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-[11px] font-bold shadow-sm flex items-center gap-1 transition">
<i class="fa-solid fa-plus text-[10px]"></i> Добавить
</button>
</div>
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">
${items.length > 0 ? listHtml : '<div class="text-center py-8 text-xs text-slate-400">Список пуст</div>'}
</div>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки реестра</div>`;
}
},
async deleteManualAbsenceRecord(id) {
if (!confirm("Удалить эту запись из реестра?")) return;
try {
const res = await fetch(`/api/v1/manual-absences/${id}`, { method: "DELETE" });
if (res.ok) {
this.renderContent();
} else {
alert("Ошибка удаления");
}
} catch (e) {
alert("Ошибка сети");
}
},
// =========================================================================
// ХАБ 4: СИСТЕМНЫЙ ПРОМПТ И БАЗА ЗНАНИЙ
// =========================================================================
renderPromptView(container) {
container.innerHTML = `
<div class="p-3 flex flex-col gap-3">
<div class="text-[11px] text-slate-600 leading-relaxed bg-white border border-slate-200 rounded-xl p-3 shadow-sm flex flex-col gap-1.5">
<span class="font-bold text-slate-800 flex items-center gap-1.5">
<i class="fa-solid fa-sliders text-indigo-600"></i> Инструкции и регламенты ИИ
</span>
<span>Управление системными директивами, базой знаний и правилами арбитража кадровых аномалий СКУД и 1С.</span>
</div>
<div class="flex flex-col gap-2">
<button onclick="window.sendChatAction('покажи системный промпт')"
class="w-full py-2 px-3 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
<i class="fa-solid fa-terminal text-xs"></i>
<span>Показать системный промпт</span>
</button>
<button onclick="window.sendChatAction('покажи правила компании')"
class="w-full py-2 px-3 bg-white hover:bg-slate-50 active:bg-slate-100 text-slate-700 border border-slate-300 rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
<i class="fa-solid fa-book-bookmark text-emerald-600 text-xs"></i>
<span>База знаний и правила компании</span>
</button>
</div>
</div>
`;
},
// =========================================================================
// ХАБ 5: УПРАВЛЕНИЕ КОНТЕКСТОМ СЕССИИ
// =========================================================================
renderContextView(container) {
container.innerHTML = `
<div class="p-3 flex flex-col gap-3">
<div class="text-[11px] text-slate-600 leading-relaxed bg-white border border-slate-200 rounded-xl p-3 shadow-sm flex flex-col gap-2">
<span class="font-bold text-slate-800 flex items-center gap-1.5">
<i class="fa-solid fa-brain text-indigo-600"></i> Управление памятью чата
</span>
<div class="flex flex-col gap-1.5 pt-1 border-t border-slate-100">
<div class="flex items-start gap-1.5">
<span class="w-2 h-2 rounded-full bg-amber-500 mt-1 shrink-0"></span>
<div>
<span class="font-bold text-slate-700">Мягкая очистка:</span>
<span class="text-slate-500"> удаляет только служебные транзакции (карточки срезов, временные превью промпта, промежуточные подтверждения). Смысловой диалог пользователя сохраняется.</span>
</div>
</div>
<div class="flex items-start gap-1.5">
<span class="w-2 h-2 rounded-full bg-rose-500 mt-1 shrink-0"></span>
<div>
<span class="font-bold text-slate-700">Полный сброс:</span>
<span class="text-slate-500"> полностью стирает контекст активной сессии из базы данных и очищает окно чата. Используется при переходе к новой дате или новой теме анализа.</span>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<button onclick="window.sendChatAction('очисти контекст')"
class="w-full py-2 px-3 bg-amber-500 hover:bg-amber-600 active:bg-amber-700 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
<i class="fa-solid fa-broom text-xs"></i>
<span>Мягкая очистка контекста</span>
</button>
<button onclick="SidebarManager.handleFullSessionReset()"
class="w-full py-2 px-3 bg-rose-600 hover:bg-rose-700 active:bg-rose-800 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
<i class="fa-solid fa-trash-arrow-up text-xs"></i>
<span>Полный сброс сессии</span>
</button>
</div>
</div>
`;
},
async handleFullSessionReset() {
if (!confirm("Вы действительно хотите полностью очистить историю диалога и сбросить сессию чата?")) {
return;
}
const chatContainer = document.getElementById("chat-messages-container");
if (chatContainer) {
chatContainer.innerHTML = `
<div class="flex gap-3 max-w-4xl mx-auto w-full">
<div class="w-7 h-7 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
<i class="fa-solid fa-robot text-xs"></i>
</div>
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
<div class="text-[10px] font-bold text-indigo-600 uppercase tracking-wider mb-1">ИИ-ассистент SCUD Orion AI</div>
<div class="text-xs text-slate-700 leading-relaxed">
Сессия чата очищена. Память ассистента сброшена. Задайте новый вопрос или команду.
</div>
</div>
</div>
`;
}
try {
await fetch("/api/v1/chat", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({
message: "сбрось сессию полностью",
session_id: "web_session_main"
})
});
} catch (e) {
console.error("Ошибка запроса сброса сессии:", e);
}
}
};
// ============================================================================
// МОДАЛЬНОЕ ОКНО ИСКЛЮЧЕНИЙ И АВТОКОМПЛИТ 1С
// ============================================================================
window.openExceptionModal = function(category, title = "") {
const modal = document.getElementById("exception-modal");
const headerText = document.getElementById("exception-modal-header-text");
const catInput = document.getElementById("exception-category-input");
const valInput = document.getElementById("exception-value-input");
const labelEl = document.getElementById("exception-value-label");
const commentInput = document.getElementById("exception-comment-input");
const errEl = document.getElementById("exception-error-msg");
const suggestionsBox = document.getElementById("exception-suggestions");
if (!modal) return;
if (errEl) errEl.classList.add("hidden");
if (suggestionsBox) {
suggestionsBox.classList.add("hidden");
suggestionsBox.innerHTML = "";
}
if (catInput) catInput.value = category;
if (commentInput) commentInput.value = "";
if (valInput) valInput.value = "";
if (headerText) headerText.innerText = title || "Добавление в реестр";
if (labelEl && valInput) {
if (category.includes("fio")) {
labelEl.innerText = "ФИО сотрудника (автоподбор из 1С):";
valInput.placeholder = "Начните вводить фамилию...";
} else if (category.includes("department")) {
labelEl.innerText = "Подразделение:";
valInput.placeholder = "Например: ЭТО, ЛЦ, ОВК";
} else {
labelEl.innerText = "Должность:";
valInput.placeholder = "Например: Уборщик, Слесарь";
}
}
modal.classList.remove("hidden");
if (valInput) valInput.focus();
};
window.closeExceptionModal = function() {
const modal = document.getElementById("exception-modal");
if (modal) modal.classList.add("hidden");
};
window.submitExceptionModalForm = async function(e) {
e.preventDefault();
const category = document.getElementById("exception-category-input").value;
const value = document.getElementById("exception-value-input").value.trim();
const comment = document.getElementById("exception-comment-input")?.value.trim() || "";
const errEl = document.getElementById("exception-error-msg");
if (!value) return;
try {
const res = await fetch("/api/v1/exceptions/", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({ category: category, value: value, comment: comment })
});
if (res.ok) {
closeExceptionModal();
if (window.SidebarManager) SidebarManager.renderContent();
} else {
const err = await res.json().catch(() => ({}));
if (errEl) {
errEl.innerText = err.detail || "Ошибка сохранения";
errEl.classList.remove("hidden");
}
}
} catch (err) {
if (errEl) {
errEl.innerText = "Ошибка соединения с сервером";
errEl.classList.remove("hidden");
}
}
};
let excSearchTimeout = null;
document.addEventListener("DOMContentLoaded", () => {
if (window.AuthManager && AuthManager.isAuthenticated()) {
SidebarManager.init();
}
const inputEl = document.getElementById("exception-value-input");
const box = document.getElementById("exception-suggestions");
if (inputEl && box) {
inputEl.addEventListener("input", function() {
const category = document.getElementById("exception-category-input")?.value || "";
if (!category.includes("fio")) {
box.classList.add("hidden");
return;
}
const val = this.value.trim();
clearTimeout(excSearchTimeout);
if (val.length < 2) {
box.classList.add("hidden");
box.innerHTML = "";
return;
}
excSearchTimeout = 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 => `
<div class="p-2 hover:bg-indigo-50 cursor-pointer border-b border-slate-100 flex flex-col text-xs"
onclick="selectExceptionStaff('${escapeHtml(it.fio)}')">
<span class="font-bold text-slate-800">${escapeHtml(it.fio)}</span>
<span class="text-[10px] text-slate-500">${escapeHtml(it.department)} · ${escapeHtml(it.position)}</span>
</div>
`).join("");
box.classList.remove("hidden");
} catch (e) {
console.error(e);
}
}, 200);
});
}
});
window.selectExceptionStaff = function(fio) {
const inputEl = document.getElementById("exception-value-input");
const box = document.getElementById("exception-suggestions");
if (inputEl) inputEl.value = fio;
if (box) {
box.classList.add("hidden");
box.innerHTML = "";
}
};
+166 -111
View File
@@ -1,145 +1,200 @@
let currentFilter = 'ALL'; /**
let allTasks = []; * ===============================================================================
* FILE: modules/web_api/static/js/tasks.js
* ROLE: Управление персональными задачами оператора (CRUD, фильтры, рендер).
* ===============================================================================
*/
function toggleDrawer() { let currentTasksFilter = 'ALL';
if (typeof IS_GUEST !== 'undefined' && IS_GUEST) return; let tasksCache = [];
const drawer = document.getElementById("task-drawer");
const backdrop = document.getElementById("drawer-backdrop");
if (!drawer) return;
const isHidden = drawer.classList.contains("translate-x-full");
if (isHidden) {
drawer.classList.remove("translate-x-full");
if (backdrop) backdrop.classList.remove("hidden");
loadTasks();
} else {
drawer.classList.add("translate-x-full");
if (backdrop) backdrop.classList.add("hidden");
}
}
function setFilter(status) { function getTasksContainer() {
currentFilter = status; return document.getElementById("tasks-list-container") ||
["ALL", "IN_PROGRESS", "BACKLOG", "COMPLETED"].forEach(f => { document.getElementById("sidebar-dynamic-content") ||
const btn = document.getElementById(`filter-${f}`); document.getElementById("tasks-list");
if (btn) {
btn.className = (f === status)
? "px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap"
: "px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap";
}
});
renderTasks();
} }
async function loadTasks() { async function loadTasks() {
const badge = document.getElementById("task-count-badge"); const container = getTasksContainer();
const container = document.getElementById("tasks-container"); if (!container) return;
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); container.innerHTML = `
const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true"); <div class="text-center py-8 text-xs text-slate-400">
<i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка задач...
if (isGuest || !token) { </div>
if (badge) badge.innerText = "0"; `;
return;
}
try { try {
const res = await fetch("/api/v1/tasks", { const res = await fetch("/api/v1/tasks", {
headers: { headers: AuthManager.getAuthHeaders()
"Authorization": "Bearer " + token,
"Content-Type": "application/json"
}
}); });
if (res.status === 401) {
if (typeof logout === 'function') logout();
return;
}
if (!res.ok) { if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`); if (res.status === 401) {
showAuthModal();
return;
}
throw new Error(`Ошибка сервера (${res.status})`);
} }
const data = await res.json(); const data = await res.json();
tasksCache = Array.isArray(data) ? data : (data.tasks || []);
// Гибкое определение структуры данных (массив или объект с ключом tasks) renderTasksUI();
if (Array.isArray(data)) {
allTasks = data;
} else if (data && Array.isArray(data.tasks)) {
allTasks = data.tasks;
} else if (data && typeof data === 'object') {
allTasks = Object.values(data).find(val => Array.isArray(val)) || [];
} else {
allTasks = [];
}
if (badge) {
badge.innerText = allTasks.length.toString();
}
renderTasks();
} catch (err) { } catch (err) {
console.error("[Tasks Error]", err); console.error("[Tasks] Ошибка загрузки:", err);
if (badge) badge.innerText = "0"; container.innerHTML = `
if (container) { <div class="p-4 text-xs text-rose-500 text-center flex flex-col items-center gap-2">
container.innerHTML = `<div class="text-red-500 text-xs py-8 text-center font-medium">Ошибка обработки списка задач</div>`; <i class="fa-solid fa-triangle-exclamation text-base"></i>
} <span>Не удалось загрузить задачи</span>
<button onclick="loadTasks()" class="px-2.5 py-1 bg-slate-200 hover:bg-slate-300 text-slate-700 rounded text-[11px] font-semibold transition">Повторить</button>
</div>
`;
} }
} }
function renderTasks() { function setTaskFilter(filter) {
const container = document.getElementById("tasks-container"); currentTasksFilter = filter;
renderTasksUI();
}
function renderTasksUI() {
const container = getTasksContainer();
if (!container) return; if (!container) return;
if (!Array.isArray(allTasks)) { let filtered = tasksCache;
allTasks = []; 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 filtered = allTasks.filter(t => currentFilter === "ALL" || t.status === currentFilter); const filtersHtml = `
<div class="flex items-center gap-1 p-1 bg-slate-200/70 rounded-lg text-[11px] font-semibold mb-2">
<button onclick="setTaskFilter('ALL')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'ALL' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">Все</button>
<button onclick="setTaskFilter('IN_PROGRESS')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'IN_PROGRESS' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">В работе</button>
<button onclick="setTaskFilter('BACKLOG')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'BACKLOG' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">Планы</button>
<button onclick="setTaskFilter('COMPLETED')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'COMPLETED' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">Готово</button>
</div>
`;
const addBtnHtml = `
<div class="flex items-center justify-between px-1 mb-1.5">
<span class="text-xs font-bold text-slate-700">Задачи: ${filtered.length}</span>
<button onclick="openCreateTaskModal()" class="px-2 py-1 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-[11px] font-bold shadow-sm flex items-center gap-1 transition">
<i class="fa-solid fa-plus text-[10px]"></i> Новая
</button>
</div>
`;
if (filtered.length === 0) { if (filtered.length === 0) {
container.innerHTML = `<div class="text-slate-400 text-xs py-8 text-center">Нет задач с выбранным фильтром</div>`; container.innerHTML = `
${filtersHtml}
${addBtnHtml}
<div class="text-center py-8 text-xs text-slate-400 bg-white border border-slate-200 rounded-xl p-4">
Нет задач в выбранной категории
</div>
`;
return; return;
} }
container.innerHTML = filtered.map(t => { const itemsHtml = filtered.map(t => {
let statusBadge = "bg-slate-100 text-slate-600 border-slate-200"; const isDone = t.status === 'COMPLETED' || t.status === 'DONE';
let cardBg = "bg-white"; const priorityColors = {
'HIGH': 'bg-rose-50 text-rose-700 border-rose-200',
if (t.status === "COMPLETED") { 'MEDIUM': 'bg-amber-50 text-amber-700 border-amber-200',
statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold"; 'LOW': 'bg-slate-50 text-slate-600 border-slate-200'
cardBg = "bg-emerald-50/20"; };
} else if (t.status === "IN_PROGRESS") { const pClass = priorityColors[t.priority] || priorityColors['MEDIUM'];
statusBadge = "bg-amber-50 text-amber-700 border-amber-300 font-bold";
cardBg = "bg-amber-50/20 border-amber-200";
}
let priorityBadge = "text-slate-500 bg-slate-100 border-slate-200";
if (t.priority === "HIGH") priorityBadge = "text-red-700 bg-red-50 border-red-200 font-bold";
let dueDateHtml = t.due_date ? `
<div class="mt-2 text-[11px] text-amber-800 bg-amber-50 border border-amber-200 px-2 py-0.5 rounded-md flex items-center gap-1.5 w-fit font-medium">
<i class="fa-solid fa-clock text-amber-600"></i>
<span>Срок: ${t.due_date}</span>
</div>` : "";
return ` return `
<div class="${cardBg} border border-slate-200 rounded-xl p-3.5 shadow-sm hover:shadow-md transition"> <div class="flex flex-col p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-1.5 shadow-sm hover:border-indigo-300 transition">
<div class="flex justify-between items-center mb-1.5"> <div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5 min-w-0">
<span class="font-mono text-xs font-bold text-slate-900 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">${t.task_id || t.id || 'TASK'}</span> <button onclick="toggleTaskStatus(${t.id}, '${t.status}')" class="text-slate-400 hover:text-indigo-600 transition shrink-0">
<span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'MEDIUM'}</span> <i class="fa-${isDone ? 'solid fa-circle-check text-emerald-500' : 'regular fa-circle'} text-sm"></i>
</button>
<span class="font-bold text-slate-800 ${isDone ? 'line-through text-slate-400' : ''} truncate">${escapeHtml(t.title)}</span>
</div> </div>
<span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${t.status || 'BACKLOG'}</span> <span class="px-1.5 py-0.5 rounded text-[9px] font-semibold border ${pClass} shrink-0">${t.priority || 'NORMAL'}</span>
</div> </div>
<h3 class="text-xs font-semibold text-slate-800 mb-1 leading-snug">${t.title || t.description || ''}</h3>
<div class="text-[10px] text-slate-400 font-mono flex items-center gap-1"> <div class="flex items-center justify-between text-[10px] text-slate-400 pt-1 border-t border-slate-100">
<i class="fa-solid fa-folder-closed text-slate-300"></i> <span>${t.task_id || ('#' + t.id)} · ${escapeHtml(t.module || 'general')}</span>
<span>${t.module || 'General'}</span> <div class="flex items-center gap-1">
<button onclick="deleteTaskItem(${t.id})" class="text-slate-400 hover:text-rose-600 p-0.5 transition" title="Удалить">
<i class="fa-solid fa-trash-can text-[11px]"></i>
</button>
</div>
</div> </div>
${dueDateHtml}
</div> </div>
`; `;
}).join(""); }).join('');
}
container.innerHTML = `
${filtersHtml}
${addBtnHtml}
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">
${itemsHtml}
</div>
`;
}
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 diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
set -e
cd /home/puh/projects/scud_ai
mkdir -p /home/puh/projects/scud_ai/logs
echo "[CRON HOURLY SNAPSHOT START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_hourly.log
# Запуск ТОЛЬКО экспорта среза СКУД без тяжелых отчетов:
/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/services/scud_export.py >> /home/puh/projects/scud_ai/logs/cron_hourly.log 2>&1
echo "[CRON HOURLY SNAPSHOT FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_hourly.log
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
set -e
cd /home/puh/projects/scud_ai
mkdir -p /home/puh/projects/scud_ai/logs
echo "==================================================" >> /home/puh/projects/scud_ai/logs/cron_etl.log
echo "[CRON START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_etl.log
echo "==================================================" >> /home/puh/projects/scud_ai/logs/cron_etl.log
/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/main_etl.py >> /home/puh/projects/scud_ai/logs/cron_etl.log 2>&1
echo "[CRON FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_etl.log
echo "" >> /home/puh/projects/scud_ai/logs/cron_etl.log
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
set -e
cd /home/puh/projects/scud_ai
mkdir -p /home/puh/projects/scud_ai/logs
echo "[CRON HOURLY START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_hourly.log
/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/services/scud_export.py >> /home/puh/projects/scud_ai/logs/cron_hourly.log 2>&1
echo "[CRON HOURLY FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_hourly.log
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
set -e
cd /home/puh/projects/scud_ai
mkdir -p /home/puh/projects/scud_ai/logs
echo "[CRON REPORTS START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_reports.log
/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/main_etl.py --use-existing-snapshot >> /home/puh/projects/scud_ai/logs/cron_reports.log 2>&1
echo "[CRON REPORTS FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_reports.log
+320 -64
View File
@@ -7,7 +7,8 @@ from datetime import datetime
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from config import DATA_DIR, DATE_TODAY, OUTPUT_DIR from core.repositories.scud_repo import get_building_presence
from config import DATA_DIR, DATE_TODAY, OUTPUT_DIR, EXCEPTIONS_PATH, normalize_fio
from core.database import ( from core.database import (
get_connection, get_connection,
get_available_snapshots, get_available_snapshots,
@@ -15,9 +16,54 @@ from core.database import (
load_scud_from_db_by_snapshot, load_scud_from_db_by_snapshot,
get_latest_snapshot_time get_latest_snapshot_time
) )
from services.exceptions_repo import (
get_all_exceptions_from_db,
add_exception_to_db,
remove_exception_from_db,
sync_json_to_db
)
DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db") DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
def cmd_mapping(args_list):
"""Управление подтвержденными сопоставлениями ФИО (СКУД <-> 1С:ЗУП)."""
with get_connection() as conn:
cursor = conn.cursor()
if not args_list or args_list[0] in ["list", "show"]:
cursor.execute("SELECT id, scud_fio, zup_fio, match_source, status FROM person_identity_mapping ORDER BY id DESC")
rows = cursor.fetchall()
print("\n🔗 СОХРАНЕННЫЕ СОПОСТАВЛЕНИЯ ФИО (person_identity_mapping):")
print("=" * 80)
if rows:
for r in rows:
print(f" #{r[0]} [{r[4]}] СКУД: '{r[1]}' ⟷ 1С: '{r[2]}' ({r[3]})")
else:
print(" — сопоставлений пока нет")
print("=" * 80 + "\n")
return
subcmd = args_list[0]
if subcmd == "add":
if len(args_list) < 3:
print("Использование: python scripts/db_cli.py mapping add 'ФИО в СКУД' 'ФИО в 1С'")
return
scud_f, zup_f = args_list[1], args_list[2]
cursor.execute("""
INSERT OR REPLACE INTO person_identity_mapping (scud_fio, zup_fio, match_source, status)
VALUES (?, ?, 'MANUAL', 'ACTIVE')
""", (scud_f.strip(), zup_f.strip()))
conn.commit()
print(f"✅ Успешно добавлена связка: '{scud_f}' ⟷ '{zup_f}'")
elif subcmd in ["del", "delete", "remove"]:
if len(args_list) < 2:
print("Использование: python scripts/db_cli.py mapping del 'ФИО в СКУД'")
return
cursor.execute("DELETE FROM person_identity_mapping WHERE scud_fio = ?", (args_list[1].strip(),))
conn.commit()
print(f"✅ Связка для '{args_list[1]}' удалена.")
def print_tool_actions(): def print_tool_actions():
"""Выводит реестр декларативных действий инструментов и шаблоны кнопок.""" """Выводит реестр декларативных действий инструментов и шаблоны кнопок."""
print("\n" + "=" * 110) print("\n" + "=" * 110)
@@ -25,25 +71,29 @@ def print_tool_actions():
print("=" * 110) print("=" * 110)
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" try:
SELECT id, tool_name, category, bypass_llm, success_template, follow_up_question, buttons_json cursor.execute("""
FROM tool_action_registry SELECT id, tool_name, category, bypass_llm, success_template, follow_up_question, buttons_json
WHERE is_active = 1 FROM tool_action_registry
ORDER BY id ASC WHERE is_active = 1
""") ORDER BY id ASC
rows = cursor.fetchall() """)
if not rows: rows = cursor.fetchall()
print("Таблица tool_action_registry пуста.") if not rows:
else: print("Таблица tool_action_registry пуста.")
for r in rows: else:
print(f"ID: {r[0]} | Tool: [{r[1]}] | Категория: {r[2]} | Bypass LLM: {'ДА (0.05с)' if r[3] else 'НЕТ'}") for r in rows:
print(f" • Сообщение: {r[4]}") print(f"ID: {r[0]} | Tool: [{r[1]}] | Категория: {r[2]} | Bypass LLM: {'ДА (0.05с)' if r[3] else 'НЕТ'}")
if r[5]: print(f" • Сообщение: {r[4]}")
print(f" • Вопрос: {r[5]}") if r[5]:
print(f" • Кнопки: {r[6]}") print(f" • Вопрос: {r[5]}")
print("-" * 110) print(f" • Кнопки: {r[6]}")
print("-" * 110)
except Exception as e:
print(f"Таблица tool_action_registry недоступна: {e}")
print("=" * 110 + "\n") print("=" * 110 + "\n")
def print_stats(): def print_stats():
"""Выводит общую статистику по записям в таблицах БД.""" """Выводит общую статистику по записям в таблицах БД."""
print("\n" + "=" * 60) print("\n" + "=" * 60)
@@ -51,11 +101,18 @@ def print_stats():
print("=" * 60) print("=" * 60)
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base'] tables = [
'scud_logs', 'scud_events_raw', 'zup_staff', 'zup_absences', 'anomalies_history',
'ai_knowledge_base', 'chat_messages', 'session_states',
'system_prompt_nodes', 'tasks', 'exceptions_registry'
]
for t in tables: for t in tables:
cursor.execute(f"SELECT COUNT(*) FROM {t}") try:
cnt = cursor.fetchone()[0] cursor.execute(f"SELECT COUNT(*) FROM {t}")
print(f" • Таблица [{t:<18}]: {cnt:>6} записей") cnt = cursor.fetchone()[0]
print(f" • Таблица [{t:<22}]: {cnt:>6} записей")
except Exception:
pass
print("=" * 60 + "\n") print("=" * 60 + "\n")
@@ -79,13 +136,11 @@ def print_snapshots_list(date_str=None):
def snapshot_sort_key(row): def snapshot_sort_key(row):
snap_id = row[0] or "" snap_id = row[0] or ""
snap_time = row[2] or "" snap_time = row[2] or ""
seq_num = 0 seq_num = 0
if "-" in snap_id: if "-" in snap_id:
parts = snap_id.replace("Y", "").split("-") parts = snap_id.replace("Y", "").split("-")
if len(parts) > 1 and parts[1].isdigit(): if len(parts) > 1 and parts[1].isdigit():
seq_num = int(parts[1]) seq_num = int(parts[1])
return (snap_time, seq_num) return (snap_time, seq_num)
sorted_rows = sorted(rows, key=snapshot_sort_key, reverse=True) sorted_rows = sorted(rows, key=snapshot_sort_key, reverse=True)
@@ -101,11 +156,7 @@ def print_snapshots_list(date_str=None):
time_part = snap_time.split(" ")[1] time_part = snap_time.split(" ")[1]
slice_datetime_str = f"{log_date} {time_part}" if time_part != "—" else log_date slice_datetime_str = f"{log_date} {time_part}" if time_part != "—" else log_date
formatted_snap_id = f" {snap_id}" if not snap_id.startswith("Y") else snap_id
if not snap_id.startswith("Y"):
formatted_snap_id = f" {snap_id}"
else:
formatted_snap_id = snap_id
print(f"{formatted_snap_id:<16} | {snap_time:<24} | {slice_datetime_str:<20} | {count:<8}") print(f"{formatted_snap_id:<16} | {snap_time:<24} | {slice_datetime_str:<20} | {count:<8}")
@@ -138,31 +189,44 @@ def inspect_scud(snapshot_id=None, date_str=None, export_xlsx=None):
"""Инспектирует логи СКУД за выбранный снапшот или дату и опционально сохраняет XLSX.""" """Инспектирует логи СКУД за выбранный снапшот или дату и опционально сохраняет XLSX."""
target_date = date_str if date_str else DATE_TODAY target_date = date_str if date_str else DATE_TODAY
print("\n" + "=" * 90) print("\n" + "=" * 115)
if snapshot_id: if snapshot_id:
print(f"🔍 ИНСПЕКЦИЯ СКУД ПО СНАПШОТУ [{snapshot_id}] (Дата: {target_date}):") print(f"🔍 ИНСПЕКЦИЯ СКУД ПО СНАПШОТУ [{snapshot_id}] (Дата среза: {target_date}):")
else: else:
print(f"🔍 ИНСПЕКЦИЯ СКУД ЗА ТЕКУЩУЮ ДАТУ [{target_date}] (ПОСЛЕДНИЙ СРЕЗ):") print(f"🔍 ИНСПЕКЦИЯ СКУД ЗА ТЕКУЩУЮ ДАТУ [{target_date}] (ПОСЛЕДНИЙ СРЕЗ):")
print("=" * 90) print("=" * 115)
df = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_id) df = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_id)
if df.empty: if df is None or df.empty:
print(f"Записи СКУД не найдены.") print("Записи СКУД не найдены.")
print("=" * 90 + "\n") print("=" * 115 + "\n")
return return
fio_col = next((c for c in ['Сотрудник', 'fio', 'fio_clean'] if c in df.columns), None)
dept_col = next((c for c in ['Подразделение', 'department_scud', 'department'] if c in df.columns), None)
pos_col = next((c for c in ['Должность', 'position'] if c in df.columns), None)
in_col = next((c for c in ['Начало_дня', 'time_in'] if c in df.columns), None)
first_act_col = next((c for c in ['Первая_активность', 'first_activity'] if c in df.columns), None)
out_col = next((c for c in ['Конец_дня', 'time_out'] if c in df.columns), None)
dur_col = next((c for c in ['Находился_в_здании', 'time_in_building', 'duration'] if c in df.columns), None)
present_col = next((c for c in ['Пришел', 'is_present'] if c in df.columns), None)
anom_col = 'anomaly_flag' if 'anomaly_flag' in df.columns else None
snap_col = 'snapshot_id' if 'snapshot_id' in df.columns else None
total = len(df) total = len(df)
present_cnt = len(df[df['Пришел'] == True]) if 'Пришел' in df.columns else 0 if present_col:
present_cnt = len(df[df[present_col].astype(str).str.lower().isin(['true', '1'])])
else:
present_cnt = 0
absent_cnt = total - present_cnt absent_cnt = total - present_cnt
print(f"Всего записей: {total} | Пришли: {present_cnt} | Не пришли: {absent_cnt}") print(f"Всего записей: {total} | Присутствовали: {present_cnt} | Отсутствовали: {absent_cnt}")
print("-" * 90) print("-" * 115)
cols_to_show = ['fio', 'department', 'position', 'time_in', 'first_activity', 'time_out', 'is_present', 'anomaly_flag', 'snapshot_id'] display_cols = [c for c in [fio_col, dept_col, in_col, first_act_col, out_col, dur_col, present_col, anom_col, snap_col] if c]
existing_cols = [c for c in cols_to_show if c in df.columns] print(df[display_cols].head(30).to_string(index=False))
print(df[existing_cols].head(30).to_string(index=False))
if len(df) > 30: if len(df) > 30:
print(f"\n... и ещё {len(df) - 30} строк.") print(f"\n... и ещё {len(df) - 30} строк.")
@@ -172,11 +236,11 @@ def inspect_scud(snapshot_id=None, date_str=None, export_xlsx=None):
out_path = os.path.join(OUTPUT_DIR, out_path) out_path = os.path.join(OUTPUT_DIR, out_path)
df.to_excel(out_path, index=False) df.to_excel(out_path, index=False)
print("\n" + "*" * 90) print("\n" + "*" * 115)
print(f"[✓] УСПЕШНЫЙ ЭКСПОРТ ДЕБАГ-ФАЙЛА В EXCEL: {out_path}") print(f"[✓] УСПЕШНЫЙ ЭКСПОРТ ДЕБАГ-ФАЙЛА В EXCEL: {out_path}")
print("*" * 90) print("*" * 115)
print("=" * 90 + "\n") print("=" * 115 + "\n")
def print_absences(date_str=None): def print_absences(date_str=None):
@@ -236,28 +300,35 @@ def dump_all_to_excel(out_filename="db_dump_full.xlsx"):
out_path = os.path.join(OUTPUT_DIR, out_filename) out_path = os.path.join(OUTPUT_DIR, out_filename)
print(f"\n[🔄] Создание полного дампа БД в файл: {out_path} ...") print(f"\n[🔄] Создание полного дампа БД в файл: {out_path} ...")
with get_connection() as conn, pd.ExcelWriter(out_path, engine='openpyxl') as writer: with get_connection() as conn, pd.ExcelWriter(out_path, engine='openpyxl') as writer:
for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base']: for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks', 'exceptions_registry']:
df = pd.read_sql_query(f"SELECT * FROM {table}", conn) try:
df.to_excel(writer, sheet_name=table[:31], index=False) df = pd.read_sql_query(f"SELECT * FROM {table}", conn)
df.to_excel(writer, sheet_name=table[:31], index=False)
except Exception:
pass
print(f"[✓] Дамп успешно сохранен: {out_path}\n") print(f"[✓] Дамп успешно сохранен: {out_path}\n")
def print_system_prompts(): def print_system_prompts():
"""Выводит все системные промпты из базы данных.""" """Выводит все системные промпты из базы данных."""
print("\n" + "=" * 80) print("\n" + "=" * 80)
print("📝 СИСТЕМНЫЕ ПРОМПТЫ (system_prompts):") print("📝 СИСТЕМНЫЕ ПРОМПТЫ (system_prompt_nodes):")
print("=" * 80) print("=" * 80)
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT id, name, is_active, updated_at, prompt_text FROM system_prompts ORDER BY id DESC") try:
rows = cursor.fetchall() cursor.execute("SELECT section_id, item_id, content, is_active, updated_at FROM system_prompt_nodes ORDER BY section_id ASC, item_id ASC")
rows = cursor.fetchall()
except Exception:
rows = []
if not rows: if not rows:
print("Таблица system_prompts пуста.") print("Таблица system_prompt_nodes пуста.")
else: else:
for r in rows: for r in rows:
print(f"ID: {r[0]} | Name: {r[1]} | Active: {r[2]} | Updated: {r[3]}") print(f"Раздел {r[0]}.{r[1]} | Active: {r[3]} | Updated: {r[4]}")
print(f" {r[2]}")
print("-" * 80) print("-" * 80)
print(f"{r[4]}\n")
print("=" * 80 + "\n") print("=" * 80 + "\n")
@@ -280,6 +351,127 @@ def print_session_states():
print("=" * 80 + "\n") print("=" * 80 + "\n")
def print_chat_messages(session_id=None, limit=50):
"""Выводит таблицу истории сообщений чата (контекст)."""
print("\n" + "=" * 105)
print(f"💬 ИСТОРИЯ СООБЩЕНИЙ ЧАТА (chat_messages) {f'для сессии: {session_id}' if session_id else 'все сессии'}:")
print("=" * 105)
with get_connection() as conn:
cursor = conn.cursor()
if session_id:
cursor.execute("""
SELECT id, session_id, role, content, is_ephemeral, created_at
FROM chat_messages
WHERE session_id = ?
ORDER BY id ASC
LIMIT ?
""", (session_id, limit))
else:
cursor.execute("""
SELECT id, session_id, role, content, is_ephemeral, created_at
FROM chat_messages
ORDER BY id DESC
LIMIT ?
""", (limit,))
rows = cursor.fetchall()
if not rows:
print("Таблица chat_messages пуста.")
else:
if not session_id:
rows = list(reversed(rows))
for r in rows:
msg_id, sess, role, content, ephemeral, created = r
eph_marker = " [ЭФЕМЕРНОЕ]" if ephemeral else ""
print(f"[{msg_id}] {created} | Сессия: {sess} | Роль: {role.upper()}{eph_marker}")
print("-" * 105)
content_preview = content if content else ""
print(f"{content_preview}")
print("=" * 105)
print("\n")
def purge_chat_context(session_id=None, purge_all=False):
"""
Очистка контекста сообщений:
- По умолчанию: удаляет эфемерные сообщения, осиротевшие превью и сбрасывает стейты сессий.
- purge_all=True (--all): полностью очищает всю таблицу chat_messages и сбрасывает сессии.
"""
with get_connection() 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")
deleted_msgs = cursor.rowcount
conn.commit()
print(f"\n[✓] Полная очистка истории выполнена! Удалено сообщений: {deleted_msgs}\n")
return
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")
deleted_msgs = cursor.rowcount
conn.commit()
print(f"\n[✓] Умная зачистка контекста выполнена! Удалено сообщений: {deleted_msgs}\n")
# ⭐️ Новые функции управления исключениями (Exceptions & Whitelist)
def print_exceptions():
"""Выводит реестр исключений и белый список сотрудников из базы SQLite."""
exc = get_all_exceptions_from_db()
print("\n" + "=" * 80)
print("📋 РЕЕСТР ИСКЛЮЧЕНИЙ И БЕЛЫЙ СПИСОК (exceptions_registry):")
print("=" * 80)
for cat, items in exc.items():
print(f"[{cat.upper()}] ({len(items)} шт.):")
if items:
for it in items:
print(f" • {it}")
else:
print(" — пусто")
print("-" * 80)
print("=" * 80 + "\n")
def print_building_presence(date_str: str, all_statuses: bool = False):
"""Выводит оперативный список сотрудников, находящихся в здании."""
records = get_building_presence(date_str, only_inside=not all_statuses)
title = f"КТО СЕЙЧАС В ЗДАНИИ [{date_str}]" if not all_statuses else f"ОПЕРАТИВНЫЙ СТАТУС СОТРУДНИКОВ [{date_str}]"
print("\n" + "=" * 80)
print(f"🏢 {title} (Всего: {len(records)})")
print("=" * 80)
if not records:
print(" Нет данных о проходах за указанную дату.")
else:
print(f"{'ФИО':<35} | {'Подразделение':<15} | {'Время':<10} | {'Статус':<8}")
print("-" * 80)
for r in records:
time_short = r['last_event_time'].split()[-1][:8] if ' ' in r['last_event_time'] else r['last_event_time'][:8]
print(f"{r['fio']:<35} | {r['department'][:15]:<15} | {time_short:<10} | {r['status']:<8}")
print("=" * 80 + "\n")
HELP_TEXT = """ HELP_TEXT = """
CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db) CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db)
@@ -287,27 +479,44 @@ CLI-утилита инспекции и управления SQLite базой
stats -- Общая статистика строк по всем таблицам БД stats -- Общая статистика строк по всем таблицам БД
snapshots [ДД.ММ.ГГГГ] -- Посмотреть реестр снапшотов (опционально за конкретную дату) snapshots [ДД.ММ.ГГГГ] -- Посмотреть реестр снапшотов (опционально за конкретную дату)
scud [ДД.ММ.ГГГГ] [--snapshot ID] [--export-xlsx NAME] -- Инспекция логов СКУД по дате/снапшоту и экспорт в Excel scud [ДД.ММ.ГГГГ] [--snapshot ID] [--export-xlsx NAME] -- Инспекция логов СКУД по дате/снапшоту и экспорт в Excel
in_building [ДД.ММ.ГГГГ] [--all] -- Оперативный статус: кто сейчас в здании (или все статусы с флагом --all)
absences [ДД.ММ.ГГГГ] -- Посмотреть список официально отсутствующих из 1С:ЗУП absences [ДД.ММ.ГГГГ] -- Посмотреть список официально отсутствующих из 1С:ЗУП
anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С
rules -- Посмотреть правила Базы Знаний ИИ из SQLite rules -- Посмотреть правила Базы Знаний ИИ из SQLite
prompts -- Посмотреть системные промпты (system_prompts) prompts -- Посмотреть узлы системного промпта (system_prompt_nodes)
tools -- Посмотреть реестр действий инструментов и кнопок (tool_action_registry) tools -- Посмотреть реестр действий инструментов и кнопок (tool_action_registry)
sessions -- Посмотреть активные сессии и превью (session_states) sessions -- Посмотреть активные сессии и превью (session_states)
context [session_id] [--limit N] -- Посмотреть таблицу контекста сообщений чата (chat_messages)
context purge [session_id] [--all] -- Очистить контекст (умная зачистка или полная с флагом --all)
dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel
snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшота по ID или всех за выбранный день snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшота по ID или всех за выбранный день
exceptions [list] -- Посмотреть реестр исключений и белый список (SQLite)
exceptions add -c CATEGORY -v VALUE [-m COMMENT] -- Добавить исключение (fio, include_fio, departments, positions, position_keywords)
exceptions del -c CATEGORY -v VALUE -- Удалить исключение из БД
exceptions sync -- Синхронизировать exceptions.json -> SQLite
ПРИМЕРЫ ЗАПУСКА: ПРИМЕРЫ ЗАПУСКА:
python scripts/db_cli.py stats python scripts/db_cli.py stats
python scripts/db_cli.py snapshots 06.08.2026 python scripts/db_cli.py snapshots 06.08.2026
python scripts/db_cli.py scud 06.08.2026 --export-xlsx срез_четверг python scripts/db_cli.py scud 06.08.2026 --export-xlsx срез_четверг
python scripts/db_cli.py scud --snapshot Y20260805-007 python scripts/db_cli.py in_building 07.09.2026
python scripts/db_cli.py in_building 07.09.2026 --all
python scripts/db_cli.py absences 07.08.2026 python scripts/db_cli.py absences 07.08.2026
python scripts/db_cli.py prompts python scripts/db_cli.py prompts
python scripts/db_cli.py tools python scripts/db_cli.py tools
python scripts/db_cli.py sessions python scripts/db_cli.py sessions
python scripts/db_cli.py context web_session_main --limit 20
python scripts/db_cli.py context purge
python scripts/db_cli.py context purge --all
python scripts/db_cli.py context purge web_session_main --all
python scripts/db_cli.py snapshot del Y20260805-007 python scripts/db_cli.py snapshot del Y20260805-007
python scripts/db_cli.py snapshot del --day 04.08.2026
python scripts/db_cli.py dump my_dump.xlsx python scripts/db_cli.py dump my_dump.xlsx
python scripts/db_cli.py exceptions
python scripts/db_cli.py exceptions add -c include_fio -v "Тарасенко Александр Александрович"
python scripts/db_cli.py exceptions add -c fio -v "Михалев Сергей Геннадьевич" -m "Уборщик"
python scripts/db_cli.py exceptions del -c fio -v "Михалев Сергей Геннадьевич"
python scripts/db_cli.py exceptions sync
""" """
@@ -321,12 +530,21 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
add_help=False add_help=False
) )
parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot', 'tools'], help="Основная команда") parser.add_argument('command', nargs='?', default=None, choices=[
parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del')") 'stats', 'snapshots', 'scud', 'absences', 'anomalies',
parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота или имя файла)") 'rules', 'prompts', 'sessions', 'dump', 'snapshot',
'tools', 'context', 'in_building', 'exceptions'
], help="Основная команда")
parser.add_argument('action', nargs='?', default=None, help="Действие ('del', 'purge', 'add', 'sync') или дата/сессия")
parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота, session_id, имя файла)")
parser.add_argument('-c', '--category', type=str, default=None, choices=['departments', 'positions', 'fio', 'position_keywords', 'include_fio', 'turnstile_fio', 'turnstile_departments'], help="Категория исключения")
parser.add_argument('-v', '--value', type=str, default=None, help="Значение исключения (ФИО, отдел, должность)")
parser.add_argument('-m', '--comment', type=str, default="", help="Комментарий к исключению")
parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции") parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции")
parser.add_argument('--export-xlsx', type=str, default=None, help="Экспорт среза СКУД в Excel-файл") parser.add_argument('--export-xlsx', type=str, default=None, help="Экспорт среза СКУД в Excel-файл")
parser.add_argument('--day', type=str, default=None, help="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)") parser.add_argument('--day', type=str, default=None, help="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)")
parser.add_argument('--limit', type=int, default=50, help="Лимит выводимых сообщений чата (для команды context)")
parser.add_argument('--all', action='store_true', help="Полная очистка всех сообщений (для команды context purge)")
if len(sys.argv) == 1: if len(sys.argv) == 1:
print_stats() print_stats()
@@ -337,12 +555,13 @@ def main():
if args.command == 'stats': if args.command == 'stats':
print_stats() print_stats()
elif args.command == 'snapshots': elif args.command == 'snapshots':
date_val = args.param or args.action date_val = args.action or args.param
print_snapshots_list(date_str=date_val) print_snapshots_list(date_str=date_val)
elif args.command == 'scud': elif args.command == 'scud':
inspect_scud(snapshot_id=args.snapshot, date_str=args.param, export_xlsx=args.export_xlsx) date_val = args.action or args.param
inspect_scud(snapshot_id=args.snapshot, date_str=date_val, export_xlsx=args.export_xlsx)
elif args.command == 'absences': elif args.command == 'absences':
date_val = args.param or args.action date_val = args.action or args.param
print_absences(date_str=date_val) print_absences(date_str=date_val)
elif args.command == 'anomalies': elif args.command == 'anomalies':
print_anomalies() print_anomalies()
@@ -354,8 +573,20 @@ def main():
print_session_states() print_session_states()
elif args.command == 'tools': elif args.command == 'tools':
print_tool_actions() print_tool_actions()
elif args.command in ('in_building', 'presence'):
# Принимаем дату из позиционного параметра action (или param), либо берем текущую
target_date = args.action if args.action else datetime.now().strftime("%d.%m.%Y")
print_building_presence(target_date, all_statuses=args.all)
elif args.command == 'context':
if args.action in ['purge', 'clear']:
is_all = args.all or (args.param == '--all')
sess_id = None if (args.param == '--all' or not args.param) else args.param
purge_chat_context(session_id=sess_id, purge_all=is_all)
else:
sess_id = args.action if args.action else None
print_chat_messages(session_id=sess_id, limit=args.limit)
elif args.command == 'dump': elif args.command == 'dump':
filename = args.param if args.param else "db_dump_full.xlsx" filename = args.action or args.param or "db_dump_full.xlsx"
dump_all_to_excel(filename) dump_all_to_excel(filename)
elif args.command == 'snapshot': elif args.command == 'snapshot':
if args.action == 'del': if args.action == 'del':
@@ -369,9 +600,34 @@ def main():
else: else:
print(f"\n[❌] Ошибка: Неизвестное действие '{args.action}' для команды snapshot.") print(f"\n[❌] Ошибка: Неизвестное действие '{args.action}' для команды snapshot.")
print("Используйте: python scripts/db_cli.py snapshot del [ID или --day 'ДД.ММ.ГГГГ']\n") print("Используйте: python scripts/db_cli.py snapshot del [ID или --day 'ДД.ММ.ГГГГ']\n")
elif args.command == 'exceptions':
if args.action == 'add':
if not args.category or not args.value:
print("\n[❌] Ошибка: Для добавления исключения укажите флаги -c/--category и -v/--value")
print("Пример: python scripts/db_cli.py exceptions add -c include_fio -v \"Тарасенко Александр Александрович\"\n")
return
if add_exception_to_db(args.category, args.value, args.comment):
print(f"\n[✓] Успешно добавлено исключение: [{args.category}] {args.value}\n")
else:
print(f"\n[❌] Ошибка добавления исключения [{args.category}] {args.value}\n")
elif args.action in ['del', 'delete', 'remove']:
if not args.category or not args.value:
print("\n[❌] Ошибка: Для удаления исключения укажите флаги -c/--category и -v/--value")
print("Пример: python scripts/db_cli.py exceptions del -c fio -v \"Михалев Сергей Геннадьевич\"\n")
return
if remove_exception_from_db(args.category, args.value):
print(f"\n[✓] Успешно удалено исключение: [{args.category}] {args.value}\n")
else:
print(f"\n[⚠️] Запись не найдена в базе: [{args.category}] {args.value}\n")
elif args.action == 'sync':
sync_json_to_db()
print("\n[✓] Синхронизация exceptions.json -> SQLite успешно завершена.\n")
else:
print_exceptions()
else: else:
print(f"\n[❌] Ошибка: Неизвестная команда.") print("\n[❌] Ошибка: Неизвестная команда.")
print(HELP_TEXT) print(HELP_TEXT)
if __name__ == "__main__": if __name__ == "__main__":
main() main()
-31
View File
@@ -1,31 +0,0 @@
import os
OUTPUT_SNAPSHOT = "project_code_snapshot.md"
# Расширения файлов для включения в снимок
ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini'}
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_orion_ai_v2.tar.gz', 'context_memory.db'}
print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...")
with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out:
out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_orion_ai_v2\n\n")
for root, dirs, files in os.walk('.'):
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
for file in sorted(files):
ext = os.path.splitext(file)[1].lower()
if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES:
filepath = os.path.join(root, file)
out.write(f"## File: `{filepath}`\n")
out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n")
try:
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
out.write(f.read())
except Exception as e:
out.write(f"// Ошибка чтения файла: {e}\n")
out.write("\n```\n\n")
print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)")
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""
===============================================================================
FILE: scripts/diagnostics/make_etl_snapshot.py
ROLE: Генерация компактного слепка ETL-конвейера, генераторов отчетов и БД.
===============================================================================
"""
import os
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
OUTPUT_FILE = os.path.join(ROOT_DIR, "etl_code_snapshot.md")
TARGET_FILES = [
# Конфигурация и точка входа
"config.py",
"exceptions.json",
"main_etl.py",
"scripts/db_cli.py",
# Крон скрипты
"scripts/cron/run_hourly_snapshot.sh",
"scripts/cron/run_reports_only.sh",
"scripts/cron/run_cron_etl.sh",
# Ядро БД
"core/connection.py",
"core/database.py",
"core/schema.py",
"core/repositories/scud_repo.py",
"core/repositories/zup_repo.py",
# Сервисный слой загрузки и реестров
"services/data_loader.py",
"services/scud_export.py",
"services/share_copier.py",
"services/excel_exporter.py",
"services/exceptions_repo.py",
"services/manual_absences_repo.py",
"services/zup_extractor.py",
"services/ai_verifier.py",
"services/knowledge_base.py",
"services/knowledge/service.py",
# Модули сборки Сводки и Отчета
"services/scud_etl/pipeline.py",
"services/scud_etl/merger.py",
"services/scud_etl/svodka_generator.py",
"services/scud_etl/otchet_generator.py",
"services/scud_etl/anomaly_detector.py",
"services/snapshots/service.py",
"services/tasks/repository.py",
"services/tasks/service.py"
]
def create_etl_snapshot():
content = ["# 📦 ETL-СЛЕПОК ИСХОДНОГО КОДА (СКУД ⟷ 1С & DB CORE)\n"]
included_count = 0
for rel_path in TARGET_FILES:
full_path = os.path.join(ROOT_DIR, rel_path)
if os.path.exists(full_path):
ext = os.path.splitext(rel_path)[1].replace(".", "")
lang_map = {
"py": "py",
"json": "json",
"sh": "bash"
}
lang = lang_map.get(ext, "text")
try:
with open(full_path, "r", encoding="utf-8") as f:
file_text = f.read()
content.append(f"## File: `./{rel_path}`\n```{lang}\n{file_text}\n```\n")
included_count += 1
except Exception as e:
print(f"[⚠️] Ошибка чтения {rel_path}: {e}")
else:
print(f"[ℹ️] Пропущен отсутствующий файл: {rel_path}")
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
f.write("\n".join(content))
size_kb = os.path.getsize(OUTPUT_FILE) / 1024
print(f"\n[✓] ETL-слепок создан: {OUTPUT_FILE}")
print(f" Включено файлов: {included_count} | Размер: {size_kb:.1f} KB\n")
if __name__ == "__main__":
create_etl_snapshot()
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""
===============================================================================
FILE: scripts/diagnostics/make_web_snapshot.py
ROLE: Генерация актуального слепка Web API, фронтенда (HTML/JS) и LLM-движка.
===============================================================================
"""
import os
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
OUTPUT_FILE = os.path.join(ROOT_DIR, "web_api_code_snapshot.md")
WEB_TARGET_FILES = [
# Главная точка входа и роутеры
"modules/web_api/main.py",
"modules/web_api/routers/chat.py",
"modules/web_api/routers/remote_workers.py",
"modules/web_api/routers/manual_absences.py",
"modules/web_api/routers/exceptions.py",
"modules/web_api/routers/snapshots.py",
"modules/web_api/routers/tasks.py",
"modules/web_api/routers/auth.py",
"modules/web_api/routers/admin.py",
"modules/web_api/routers/files.py",
"modules/web_api/routers/context.py",
# LLM ядро
"modules/web_api/llm/agent.py",
"modules/web_api/llm/schemas.py",
"modules/web_api/llm/db_tools.py",
"modules/web_api/llm/core/fast_path.py",
"modules/web_api/llm/core/context_manager.py",
"modules/web_api/llm/core/ollama_client.py",
"modules/web_api/llm/core/tool_injector.py",
# Фронтенд (Разметка и клиентские скрипты)
"modules/web_api/static/index.html",
"modules/web_api/static/js/sidebar.js",
"modules/web_api/static/js/tasks.js",
"modules/web_api/static/js/manual_absences.js",
"modules/web_api/static/js/chat/core.js",
"modules/web_api/static/js/chat/task_widget.js",
"modules/web_api/static/js/auth.js",
"modules/web_api/static/js/app.js"
]
def create_web_snapshot():
content = ["# 🌐 WEB API & FRONTEND CODE SNAPSHOT\n"]
included_count = 0
for rel_path in WEB_TARGET_FILES:
full_path = os.path.join(ROOT_DIR, rel_path)
if os.path.exists(full_path):
ext = os.path.splitext(rel_path)[1].replace(".", "")
lang_map = {
"js": "js",
"py": "py",
"html": "html",
"css": "css",
"json": "json"
}
lang = lang_map.get(ext, "text")
try:
with open(full_path, "r", encoding="utf-8") as f:
file_text = f.read()
content.append(f"## File: `./{rel_path}`\n```{lang}\n{file_text}\n```\n")
included_count += 1
except Exception as e:
print(f"[⚠️] Ошибка чтения {rel_path}: {e}")
else:
print(f"[ℹ️] Пропущен отсутствующий файл: {rel_path}")
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
f.write("\n".join(content))
size_kb = os.path.getsize(OUTPUT_FILE) / 1024
print(f"\n[✓] Web API + Фронтенд слепок создан: {OUTPUT_FILE}")
print(f" Включено файлов: {included_count} | Размер: {size_kb:.1f} KB\n")
if __name__ == "__main__":
create_web_snapshot()
@@ -0,0 +1,22 @@
import os
OUTPUT_SNAPSHOT = "web_api_code_snapshot.md"
ALLOWED_EXTENSIONS = {'.py', '.html', '.css', '.js'}
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project', 'docs', 'data'}
print(f"🔄 Сборка Web API слепка кода в {OUTPUT_SNAPSHOT}...")
with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out:
out.write("# 📦 WEB API & AI СЛЕПОК ИСХОДНОГО КОДА\n\n")
if os.path.exists('modules'):
for r, d, files in os.walk('modules'):
d[:] = [sub for sub in d if sub not in EXCLUDE_DIRS]
for file in sorted(files):
ext = os.path.splitext(file)[1].lower()
if ext in ALLOWED_EXTENSIONS:
filepath = os.path.join(r, file)
out.write(f"## File: `./{filepath}`\n```" + ext.replace('.', '') + "\n")
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
out.write(f.read())
out.write("\n```\n\n")
print(f"✓ Web API слепок готов: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT) / 1024:.1f} KB)")
+30 -11
View File
@@ -151,7 +151,7 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
if not unrecognized_scud_fios or not staff_fios: if not unrecognized_scud_fios or not staff_fios:
return {} return {}
# 🛡 Исключаем точные совпадения (чтобы ИИ не совершал ложных замен) # Исключаем точные совпадения
staff_fios_set = set(staff_fios) staff_fios_set = set(staff_fios)
real_unrecognized = [f for f in unrecognized_scud_fios if f not in staff_fios_set] real_unrecognized = [f for f in unrecognized_scud_fios if f not in staff_fios_set]
@@ -163,7 +163,7 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
prompt = f""" prompt = f"""
Ты — кадровый аудитор безопасности СКУД. Ты — кадровый аудитор безопасности СКУД.
{rules_context} {rules_context}
В СКУД записаны неопознанные ФИО: {json.dumps(unrecognized_scud_fios, ensure_ascii=False)} В СКУД записаны неопознанные ФИО: {json.dumps(real_unrecognized, ensure_ascii=False)}
В официальном Штатном расписании 1С записаны ЭТАЛОНЫ: {json.dumps(staff_fios, ensure_ascii=False)} В официальном Штатном расписании 1С записаны ЭТАЛОНЫ: {json.dumps(staff_fios, ensure_ascii=False)}
СТРОГИЕ ПРАВИЛА: СТРОГИЕ ПРАВИЛА:
@@ -173,40 +173,59 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
4. В поле "warning" опиши обнаруженную опечатку. 4. В поле "warning" опиши обнаруженную опечатку.
5. Запрещено выдумывать опечатки и объединять разных людей/однофамильцев! 5. Запрещено выдумывать опечатки и объединять разных людей/однофамильцев!
ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ JSON! ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ ВАЛИДНОГО JSON:
Формат ответа:
{{ {{
"verified_matches": [ "verified_matches": [
{{ {{
"scud_fio": "ФИО из СКУД", "scud_fio": "ФИО из СКУД",
"staff_fio": "эталон ФИО из Штат 1С", "staff_fio": "эталон ФИО из Штат 1С",
"warning": "Описание опечатки в СКУД или 'Точное совпадение (без опечаток)'" "warning": "Описание опечатки в СКУД"
}} }}
] ]
}} }}
""" """
raw_response = ask_ollama( raw_response = ask_ollama(
prompt, prompt,
system_prompt="Ты — JSON API. Выдавай ТОЛЬКО валидный JSON без markdown-разметки." system_prompt="Ты — строгий JSON API генератор. Отвечай только валидным JSON объектом без пояснительного текста."
) )
mapping = {} mapping = {}
if not raw_response:
return mapping
# Фаза 1: Попытка прямого разбора с санитарной очисткой
try: try:
match = re.search(r'\{.*\}', raw_response, re.DOTALL) match = re.search(r'\{.*\}', raw_response, re.DOTALL)
if match: if match:
json_str = match.group(0) json_str = match.group(0)
json_str = re.sub(r'\}\s*[^}\]]*$', '}', json_str) # Убираем висячие запятые: {"a": 1,} -> {"a": 1}
json_str = re.sub(r',\s*([\}\]])', r'\1', json_str)
# Заменяем одинарные кавычки в ключах/значениях на двойные при необходимости
json_str = re.sub(r"(?<=\{|\,)\s*'([^']+)'\s*:", r'"\1":', json_str)
data = json.loads(json_str) data = json.loads(json_str)
for item in data.get("verified_matches", []): for item in data.get("verified_matches", []):
scud_f = item.get("scud_fio") scud_f = item.get("scud_fio")
staff_f = item.get("staff_fio") staff_f = item.get("staff_fio")
warn = item.get("warning", "Точное совпадение (без опечаток)") warn = item.get("warning", "Точное совпадение (без опечаток)")
if scud_f and staff_f: if scud_f and staff_f and staff_f in staff_fios_set:
mapping[scud_f] = {"staff_fio": staff_f, "warning": warn} mapping[scud_f] = {"staff_fio": staff_f, "warning": warn}
return mapping
except Exception:
pass
# Фаза 2: Резервный Regex-парсер (если JSON синтаксически сломан, но пары ключ-значение есть)
try:
pattern = r'["\']scud_fio["\']\s*:\s*["\']([^"\']+)["\'].*?["\']staff_fio["\']\s*:\s*["\']([^"\']+)["\']'
matches = re.findall(pattern, raw_response, re.DOTALL)
for scud_f, staff_f in matches:
scud_clean = scud_f.strip()
staff_clean = staff_f.strip()
if staff_clean in staff_fios_set:
mapping[scud_clean] = {"staff_fio": staff_clean, "warning": "Восстановлено парсером опечаток"}
except Exception as e: except Exception as e:
print(f"[!] Ошибка разбора JSON от ИИ при сверке опечаток: {e}") print(f"[!] Ошибка резервного парсинга опечаток: {e}")
return mapping return mapping
+150 -169
View File
@@ -1,11 +1,18 @@
"""
===============================================================================
FILE: services/data_loader.py
ROLE: Надежная загрузка штата и отсутствий (MS SQL ЗУП -> Резервный Excel).
===============================================================================
"""
import os import os
import pandas as pd
import warnings import warnings
from datetime import datetime # <-- ДОБАВИТЬ ЭТУ СТРОКУ
import pandas as pd
from config import ( from config import (
normalize_fio, clean_scud_fio_light, load_exceptions, normalize_fio, clean_scud_fio_light, load_exceptions,
DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR, DATA_DIR
) )
# Импортируем прямую SQL-выгрузку отсутствий из 1С и функции работы с SQLite
from services.zup_extractor import fetch_zup_absences_from_sql from services.zup_extractor import fetch_zup_absences_from_sql
from core.database import ( from core.database import (
load_scud_from_db_by_snapshot, load_scud_from_db_by_snapshot,
@@ -16,23 +23,8 @@ from core.database import (
warnings.filterwarnings('ignore', category=UserWarning, module='pandas') warnings.filterwarnings('ignore', category=UserWarning, module='pandas')
def is_excluded(fio, dept, pos, exceptions):
"""Проверяет, входит ли сотрудник или должность в список исключений."""
fio_clean = normalize_fio(fio)
dept_str = str(dept).strip().upper() if pd.notna(dept) else ""
pos_str = str(pos).strip().lower() if pd.notna(pos) else ""
if fio_clean in exceptions["fio"] or dept_str in exceptions["departments"] or pos_str in exceptions["positions"]:
return True
if any(keyword in pos_str for keyword in exceptions["position_keywords"]):
return True
return False
def load_staff_data(date_str): def load_staff_data(date_str):
""" """Загружает файл Штатного расписания 1С."""
Загружает файл Штатного расписания 1С за конкретную дату из папки 1c.
"""
filepath = find_dated_file("Штат", date_str) filepath = find_dated_file("Штат", date_str)
if not filepath: if not filepath:
fallback_path = os.path.join(ZUP_1C_DIR, "штат.xlsx") fallback_path = os.path.join(ZUP_1C_DIR, "штат.xlsx")
@@ -43,8 +35,6 @@ def load_staff_data(date_str):
try: try:
df_raw = pd.read_excel(filepath, skiprows=8) df_raw = pd.read_excel(filepath, skiprows=8)
# Индексы столбцов в файле 1С: 1 — ФИО, 5 — Подразделение, 12 — Должность
df_staff = df_raw.iloc[:, [1, 5, 12]].copy() df_staff = df_raw.iloc[:, [1, 5, 12]].copy()
df_staff.columns = ['ФИО', 'Подразделение', 'Должность'] df_staff.columns = ['ФИО', 'Подразделение', 'Должность']
@@ -57,58 +47,168 @@ def load_staff_data(date_str):
return None return None
def load_absent_from_excel(date_str):
"""Резервное чтение файла Отсутствия_ДД_ММ_ГГГГ.xlsx."""
filepath = find_dated_file("Отсутствия", date_str)
if not filepath:
return None
try:
for skip in [0, 1, 2, 3, 4, 5, 8]:
df_try = pd.read_excel(filepath, skiprows=skip)
fio_col = None
reason_col = None
for col in df_try.columns:
c_str = str(col).lower()
if ('фио' in c_str or 'сотрудник' in c_str) and fio_col is None:
fio_col = col
if ('вид' in c_str or 'причина' in c_str or 'отсутств' in c_str) and reason_col is None:
reason_col = col
if fio_col and reason_col:
df_res = df_try[[fio_col, reason_col]].copy()
df_res.columns = ['ФИО', 'Вид_отсутствия']
df_res = df_res.dropna(subset=['ФИО', 'Вид_отсутствия'])
df_res = df_res[~df_res['ФИО'].astype(str).str.contains('Всего|Организация|Сотрудник|ФИО|ЛЕНМОРНИИПРОЕКТ', case=False, na=False)]
df_res['fio_clean'] = df_res['ФИО'].apply(normalize_fio)
print(f" [✓] Резервный Excel: загружено {len(df_res)} записей отсутствий из {os.path.basename(filepath)}")
return df_res[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean'])
except Exception as e:
print(f" [⚠️] Ошибка чтения резервного Excel отсутствий {filepath}: {e}")
return None
def load_absent_data(date_str): def load_absent_data(date_str):
""" """
Загружает документально подтвержденные отсутствия сотрудников Загружает отсутствия из MS SQL 1С:ЗУП, а при сбое — из локального Excel.
НАПРЯМУЮ из базы MS SQL 1С:ЗУП за указанную дату,
а также автоматически обогащает их удаленщиками из static_reason_workers.csv.
""" """
df_absent = None df_absent = None
try: try:
df_absent = fetch_zup_absences_from_sql(date_str) df_absent = fetch_zup_absences_from_sql(date_str)
if df_absent is not None and not df_absent.empty: if df_absent is not None and not df_absent.empty:
print(f" [✓] MS SQL ЗУП: получено {len(df_absent)} записей отсутствий за {date_str}")
df_absent = df_absent.dropna(subset=['ФИО', 'Вид_отсутствия']) df_absent = df_absent.dropna(subset=['ФИО', 'Вид_отсутствия'])
df_absent = df_absent[~df_absent['ФИО'].astype(str).str.contains('АО "ЛЕНМОРНИИПРОЕКТ"|Сотрудник', case=False, na=False)] df_absent = df_absent[~df_absent['ФИО'].astype(str).str.contains('АО "ЛЕНМОРНИИПРОЕКТ"|Сотрудник', case=False, na=False)]
df_absent['fio_clean'] = df_absent['ФИО'].apply(normalize_fio) df_absent['fio_clean'] = df_absent['ФИО'].apply(normalize_fio)
df_absent = df_absent[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean']) df_absent = df_absent[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean'])
else:
df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия'])
except Exception as e: except Exception as e:
print(f"[⚠️] Ошибка SQL-выгрузки отсутствий за {date_str}: {e}") print(f" [⚠️] Ошибка подключения к MS SQL ЗУП за {date_str}: {e}")
# Резервный источник: Excel файл из 1С с сетевой шары
if df_absent is None or df_absent.empty:
df_absent = load_absent_from_excel(date_str)
if df_absent is None:
df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия']) df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия'])
# ⭐️ ОБОГАЩЕНИЕ УДАЛЕНЩИКАМИ ИЗ static_reason_workers.csv # Обогащение удаленщиками из static_reason_workers.csv с ротацией просроченных записей
try: try:
from config import DATA_DIR
static_path = os.path.join(DATA_DIR, "static_reason_workers.csv") static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
if os.path.exists(static_path): if os.path.exists(static_path):
df_static = pd.read_csv(static_path, encoding='utf-8') df_static = pd.read_csv(static_path, dtype=str, on_bad_lines='skip').fillna("")
if 'fio' in df_static.columns and 'reason' in df_static.columns:
df_static['fio_clean'] = df_static['fio'].apply(normalize_fio) for col in ['fio', 'reason', 'department', 'date_from', 'date_to']:
existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set() if col not in df_static.columns:
df_static[col] = ""
new_rows = []
for _, s_row in df_static.iterrows(): today_date = datetime.now().date()
if s_row['fio_clean'] not in existing_fios:
new_rows.append({ # Разбор целевой даты отчета
'fio_clean': s_row['fio_clean'], clean_target_str = str(date_str).replace('_', '.')
'Вид_отсутствия': s_row['reason'] try:
clean_target_date = datetime.strptime(clean_target_str, "%d.%m.%Y").date()
except ValueError:
clean_target_date = today_date
def parse_date_safe(d_val):
if not d_val or str(d_val).lower() in ['nan', 'none', '', 'nat']:
return None
s = str(d_val).strip().replace('_', '.')
for fmt in ("%d.%m.%Y", "%Y-%m-%d"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
pass
return None
active_for_file = []
new_rows_for_report = []
file_changed = False
existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set()
for _, s_row in df_static.iterrows():
fio_raw = str(s_row.get('fio', '')).strip()
if not fio_raw:
continue
fio_c = normalize_fio(fio_raw)
reason = str(s_row.get('reason', '')).strip() or "Дистанционная работа"
d_from = parse_date_safe(s_row.get('date_from'))
d_to = parse_date_safe(s_row.get('date_to'))
# 1. Физическая ротация просроченных: только если текущий реальный день (today) строго больше date_to
if d_to is not None and today_date > d_to:
print(f" [🧹] Удаленка истекла: {fio_raw} (до {d_to.strftime('%d.%m.%Y')}). Удалена из CSV.")
file_changed = True
continue
active_for_file.append(s_row.to_dict())
# 2. Проверка действия удаленки на дату формируемого отчета:
# Если date_from не указана — действует всегда до date_to
is_after_start = (d_from is None) or (clean_target_date >= d_from)
is_before_end = (d_to is None) or (clean_target_date <= d_to)
if is_after_start and is_before_end:
if fio_c not in existing_fios:
new_rows_for_report.append({
'fio_clean': fio_c,
'Вид_отсутствия': reason
}) })
if new_rows: existing_fios.add(fio_c)
df_absent = pd.concat([df_absent, pd.DataFrame(new_rows)], ignore_index=True)
# Перезаписываем CSV только если реально были удалены просроченные сотрудники
if file_changed:
pd.DataFrame(active_for_file).to_csv(static_path, index=False, encoding='utf-8')
print(" [✓] Файл static_reason_workers.csv синхронизирован без просроченных записей.")
if new_rows_for_report:
df_absent = pd.concat([df_absent, pd.DataFrame(new_rows_for_report)], ignore_index=True)
print(f" [✓] Реестр удаленщиков: добавлено {len(new_rows_for_report)} чел. в отчет за {date_str}")
except Exception as e: except Exception as e:
print(f"[⚠️] Ошибка обогащения удаленщиками в load_absent_data: {e}") print(f" [⚠️] Ошибка обработки static_reason_workers.csv: {e}")
# Обогащение реестрами "Мест. командир." и "Иное"
try:
from services.manual_absences_repo import get_active_manual_absences_for_date
manual_records = get_active_manual_absences_for_date(date_str)
if manual_records:
existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set()
manual_rows = []
for r in manual_records:
fc = r['fio_clean']
if fc not in existing_fios:
label = "Мест. командир." if r['absence_type'] == 'LOCAL_TRIP' else "Иное"
manual_rows.append({
'fio_clean': fc,
'Вид_отсутствия': label
})
existing_fios.add(fc)
if manual_rows:
df_absent = pd.concat([df_absent, pd.DataFrame(manual_rows)], ignore_index=True)
print(f" [✓] Реестры 'Мест. командир.' / 'Иное': добавлено {len(manual_rows)} чел. в отчет за {date_str}")
except Exception as e:
print(f" [⚠️] Ошибка применения manual_absences: {e}")
return df_absent if not df_absent.empty else None return df_absent if not df_absent.empty else None
def load_1c_data_smart(date_str, use_db=False): def load_1c_data_smart(date_str, use_db=False):
"""
Универсальный загрузчик данных 1С (Штат и Отсутствия).
Если use_db=True, приоритетно извлекает сохраненные данные из SQLite БД
за целевую дату указанного снапшота.
"""
df_staff = None df_staff = None
df_absent = None df_absent = None
@@ -116,128 +216,9 @@ def load_1c_data_smart(date_str, use_db=False):
df_staff = load_staff_from_db(date_str) df_staff = load_staff_from_db(date_str)
df_absent = load_absences_from_db(date_str) df_absent = load_absences_from_db(date_str)
# Если в базе нет записей за эту дату или use_db=False — загружаем из файлов/SQL
if df_staff is None: if df_staff is None:
df_staff = load_staff_data(date_str) df_staff = load_staff_data(date_str)
if df_absent is None: if df_absent is None:
df_absent = load_absent_data(date_str) df_absent = load_absent_data(date_str)
return df_staff, df_absent return df_staff, df_absent
def process_scud_anomalies(df_scud, exceptions=None):
"""
Вычисляет 'Первую активность' и маркирует аномалии СКУД.
"""
if exceptions is None:
exceptions = load_exceptions()
start_col = 'Начало_дня' if 'Начало_дня' in df_scud.columns else ('Начало дня' if 'Начало дня' in df_scud.columns else None)
end_col = 'Конец_дня' if 'Конец_дня' in df_scud.columns else ('Конец дня' if 'Конец дня' in df_scud.columns else None)
status_col = 'Статус' if 'Статус' in df_scud.columns else None
first_activities = []
anomaly_flags = []
is_present_flags = []
for _, row in df_scud.iterrows():
fio = str(row.get('Сотрудник', row.get('fio_raw', ''))).strip()
fio_clean = normalize_fio(fio)
dept = str(row.get('Подразделение', '')).strip()
pos = str(row.get('Должность', '')).strip()
val_start = str(row.get(start_col, '')).strip() if start_col else ''
val_end = str(row.get(end_col, '')).strip() if end_col else ''
val_status = str(row.get(status_col, '')).strip().lower() if status_col else ''
raw_first_event = row.get('raw_first_event', None)
if pd.isna(raw_first_event) or str(raw_first_event).strip() in ['', 'None', 'nan', '00:00:00']:
raw_first_event = None
has_no_in = (val_start in ['нет входа', 'none', 'nan', '', '00:00:00', '00:00'])
has_no_out = (val_end in ['нет выхода', 'none', 'nan', '', '00:00:00', '00:00'])
first_act = '—'
anom_flag = 'NONE'
is_present = not ('отсутствовал' in val_status or has_no_in)
if has_no_in:
if raw_first_event is not None or not has_no_out:
first_act = str(raw_first_event) if raw_first_event else val_end
anom_flag = 'ANOMALY_NO_IN_HAS_ACTIVITY'
if not is_excluded(fio, dept, pos, exceptions):
is_present = False
else:
first_act = '—'
is_present = False
first_activities.append(first_act)
anomaly_flags.append(anom_flag)
is_present_flags.append(is_present)
df_scud['Первая_активность'] = first_activities
df_scud['anomaly_flag'] = anomaly_flags
df_scud['Пришел'] = is_present_flags
return df_scud
def load_scud_data(target_date_type="today", snapshot_param=None):
"""
Загружает логи СКУД за 'today' или 'yesterday' НАПРЯМУЮ из локальной базы SQLite.
"""
target_date = DATE_TODAY if target_date_type == "today" else DATE_YESTERDAY
df_scud = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_param)
if df_scud is None or df_scud.empty:
df_scud = load_scud_from_db_by_snapshot(DATE_TODAY, snapshot_param=snapshot_param)
if df_scud is None or df_scud.empty:
print(f"[ℹ️] В базе SQLite / файлах отсутствует СКУД за {target_date}. Возвращаем пустой набор.")
return pd.DataFrame()
exceptions = load_exceptions()
if 'fio_clean' not in df_scud.columns:
fio_raw_col = 'fio' if 'fio' in df_scud.columns else ('Сотрудник' if 'Сотрудник' in df_scud.columns else df_scud.columns[0])
df_scud['fio_raw'] = df_scud[fio_raw_col].astype(str)
df_scud['fio_clean'] = df_scud['fio_raw'].apply(clean_scud_fio_light)
if 'Начало_дня' not in df_scud.columns and 'time_in' in df_scud.columns:
df_scud['Начало_дня'] = df_scud['time_in']
if 'Конец_дня' not in df_scud.columns and 'time_out' in df_scud.columns:
df_scud['Конец_дня'] = df_scud['time_out']
if 'Первая_активность' not in df_scud.columns and 'first_activity' in df_scud.columns:
df_scud['Первая_активность'] = df_scud['first_activity']
if 'Пришел' not in df_scud.columns and 'is_present' in df_scud.columns:
df_scud['Пришел'] = df_scud['is_present'].astype(bool)
df_scud['is_excluded'] = df_scud.apply(
lambda r: is_excluded(r.get('fio', r.get('Сотрудник', '')), r.get('department', r.get('Подразделение', '')), r.get('position', r.get('Должность', '')), exceptions),
axis=1
)
return df_scud
def load_all_data():
"""
Загружает слитные датасеты за Сегодня и за Вчера напрямую из базы SQLite и 1С.
"""
df_scud_today = load_scud_data(target_date_type="today")
try:
df_scud_yesterday = load_scud_data(target_date_type="yesterday")
except FileNotFoundError:
df_scud_yesterday = df_scud_today.copy()
df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY)
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY)
return (
df_scud_today,
df_scud_yesterday,
df_staff_today,
df_absent_today,
df_staff_yesterday,
df_absent_yesterday
)
+360 -252
View File
@@ -1,58 +1,96 @@
import math """
import os ===============================================================================
import time FILE: services/excel_exporter.py
import openpyxl ROLE: Генерация Excel-отчетов (Сводка, Детальный отчет, Сырой СКУД) через XlsxWriter.
import pandas as pd Корректный расчет часов удаленщиков и исключение лишних списков.
from datetime import datetime ===============================================================================
from openpyxl import Workbook """
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
from config import OUTPUT_DIR
# Словарь месяцев для текстового формата в названиях файлов import os
MONTHS_RU = { import math
import time
import pandas as pd
import xlsxwriter
from datetime import datetime, timedelta
from xlsxwriter.exceptions import FileCreateError
from config import REPORTS_DIR
MONTHS_RU_GENITIVE = {
1: "января", 2: "февраля", 3: "марта", 4: "апреля", 1: "января", 2: "февраля", 3: "марта", 4: "апреля",
5: "мая", 6: "июня", 7: "июля", 8: "августа", 5: "мая", 6: "июня", 7: "июля", 8: "августа",
9: "сентября", 10: "октября", 11: "ноября", 12: "декабря" 9: "сентября", 10: "октября", 11: "ноября", 12: "декабря"
} }
MONTHS_RU_NOMINATIVE = {
1: "январь", 2: "февраль", 3: "март", 4: "апрель",
5: "май", 6: "июнь", 7: "июль", 8: "август",
9: "сентябрь", 10: "октябрь", 11: "ноябрь", 12: "декабрь"
}
def format_date_ru(date_str): def format_date_ru(date_str):
"""Преобразует дату формата '27.07.2026' в '27 июля 2026'""" date_clean = str(date_str).replace('_', '.')
try: try:
dt = datetime.strptime(date_str, "%d.%m.%Y") dt = datetime.strptime(date_clean, "%d.%m.%Y")
return f"{dt.day} {MONTHS_RU[dt.month]} {dt.year}" return f"{dt.day} {MONTHS_RU_GENITIVE[dt.month]} {dt.year}"
except Exception: except Exception:
return date_str return date_str
# Границы ячеек def get_dated_reports_dir(date_str):
THIN_SIDE = Side(border_style="thin", color="D3D3D3") date_clean = str(date_str).replace('_', '.')
THIN_BORDER = Border(left=THIN_SIDE, right=THIN_SIDE, top=THIN_SIDE, bottom=THIN_SIDE) try:
dt = datetime.strptime(date_clean, "%d.%m.%Y")
year_str = str(dt.year)
month_name = MONTHS_RU_NOMINATIVE[dt.month]
except Exception:
now = datetime.now()
year_str = str(now.year)
month_name = MONTHS_RU_NOMINATIVE[now.month]
# Палитра заливки для Сводки target_dir = os.path.join(REPORTS_DIR, year_str, month_name)
FILL_HEADER = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") os.makedirs(target_dir, exist_ok=True)
FILL_TOTAL_LIST = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") return target_dir
FILL_UNEXPLAINED = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid")
FILL_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
FILL_NO_PASS = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") # Мягкий пастельно-голубой для "Нет пропуска"
CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "E8F8F5", "FCF3CF"]
# Палитра для Детального отчета def safe_close_workbook(wb, output_path, target_dir, filename):
YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # Обычные отсутствия try:
LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") # Потенциальные прогулы wb.close()
GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") # Аномалии (пришел в отпуске) print(f"[✓] Успешно сохранен: {output_path}")
LIGHT_BLUE_FILL = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") # Бледно-голубой для "Нет пропуска" return output_path
except (FileCreateError, OSError, PermissionError):
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
alt_path = os.path.join(target_dir, alt_filename)
try:
wb.filename = alt_path
wb._store_workbook()
print(f"[⚠️] Исходный файл открыт в Excel! Сохранено как: {alt_path}")
return alt_path
except Exception as e:
print(f"[❌] Ошибка сохранения даже резервного файла: {e}")
return output_path
def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30): def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30):
"""Рассчитывает точное отклонение от нормы с учетом обеда 30 мин и уважительных причин""" """
if pd.notna(reason) and isinstance(reason, str) and reason.strip() != "": Расчет отклонения от нормы.
Для удаленщиков при наличии физического времени в здании вычисляется реальное отклонение.
"""
reason_clean = str(reason).strip().lower() if pd.notna(reason) else ""
is_remote = "удален" in reason_clean or "дистанцион" in reason_clean
has_building_time = isinstance(time_in_building_str, str) and time_in_building_str not in ['00:00', '0', '', 'None', 'nan', 'NaN']
# Если есть уважительная причина (больничный, отпуск, командировка и т.д.) не удаленка
if reason_clean != "" and not is_remote:
return "0:00" return "0:00"
if not isinstance(time_in_building_str, str) or time_in_building_str in ['00:00', '0', '', 'None', 'nan', 'NaN']: # Если удаленщик работал исключительно из дома (00:00 в здании)
if is_remote and not has_building_time:
return "0:00"
# Если сотрудника не было в здании и нет уважительной причины
if not has_building_time:
return f"-{norm_hours}:00" return f"-{norm_hours}:00"
try: try:
@@ -62,7 +100,7 @@ def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_min
total_in_building_minutes = hh * 60 + mm total_in_building_minutes = hh * 60 + mm
if total_in_building_minutes == 0: if total_in_building_minutes == 0:
return f"-{norm_hours}:00" return "0:00" if is_remote else f"-{norm_hours}:00"
work_minutes = max(0, total_in_building_minutes - lunch_minutes) work_minutes = max(0, total_in_building_minutes - lunch_minutes)
norm_minutes = norm_hours * 60 norm_minutes = norm_hours * 60
@@ -78,157 +116,195 @@ def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_min
return f"{sign}{res_hh}:{res_mm:02d}" return f"{sign}{res_hh}:{res_mm:02d}"
except Exception: except Exception:
return f"-{norm_hours}:00" return "0:00" if is_remote else f"-{norm_hours}:00"
def apply_borders_to_cell(cell, border=THIN_BORDER): # =============================================================================
cell.border = border # 1. ЕЖЕДНЕВНАЯ СВОДКА НА СЕГОДНЯ
# =============================================================================
def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_font=None, wrap_b=False): date_clean = str(date_str).replace('_', '.')
cell_a = ws.cell(row=r_num, column=1)
cell_b = ws.cell(row=r_num, column=2)
if fill_obj:
cell_a.fill = fill_obj
cell_b.fill = fill_obj
apply_borders_to_cell(cell_a)
apply_borders_to_cell(cell_b)
if is_bold and bold_font:
cell_a.font = bold_font
cell_b.font = bold_font
if align_b:
cell_b.alignment = Alignment(horizontal=align_b, vertical="center", wrap_text=wrap_b)
# --- 1. СВОДКА НА СЕГОДНЯ ---
def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
if not filename: if not filename:
filename = f"{format_date_ru(date_str)} сводка.xlsx" filename = f"{format_date_ru(date_clean)} сводка.xlsx"
output_path = os.path.join(OUTPUT_DIR, filename) target_dir = get_dated_reports_dir(date_clean)
wb = Workbook() output_path = os.path.join(target_dir, filename)
ws = wb.active
ws.title = "Лист_1"
ws.sheet_properties.outlinePr.summaryBelow = False wb = xlsxwriter.Workbook(output_path)
ws.sheet_properties.outlinePr.summaryRight = False ws = wb.add_worksheet("Лист_1")
ws.sheet_properties.outlinePr.showOutlineSymbols = True
ws.sheet_view.showOutlineSymbols = True
bold_font = Font(name="Calibri", size=11, bold=True) ws.outline_settings(visible=True, symbols_below=False, symbols_right=False, auto_style=False)
# 1. Шапка def make_fmt(bg_color=None, bold=False, align="left", wrap=False):
ws.cell(row=1, column=1, value="Сводка на") d = {
ws.cell(row=1, column=2, value=date_str) 'font_name': 'Calibri',
format_row_cells(ws, 1, FILL_HEADER, is_bold=True, bold_font=bold_font) 'font_size': 11,
'bold': bold,
'align': align,
'valign': 'vcenter',
'border': 1,
'border_color': '#D3D3D3',
'text_wrap': wrap
}
if bg_color:
d['bg_color'] = bg_color
return wb.add_format(d)
apply_borders_to_cell(ws.cell(row=2, column=1)) fmt_hdr_l = make_fmt(bg_color='#D9E1F2', bold=True, align="left")
apply_borders_to_cell(ws.cell(row=2, column=2)) fmt_hdr_r = make_fmt(bg_color='#D9E1F2', bold=True, align="right")
fmt_tot_l = make_fmt(bg_color='#F2F2F2', bold=True, align="left")
fmt_tot_r = make_fmt(bg_color='#F2F2F2', bold=True, align="right")
fmt_empty = make_fmt()
# 2. По списку ws.set_row(0, 20)
ws.cell(row=3, column=1, value="По списку") ws.write(0, 0, "Сводка на", fmt_hdr_l)
ws.cell(row=3, column=2, value=len(merged_df)) ws.write(0, 1, date_clean, fmt_hdr_r)
format_row_cells(ws, 3, FILL_TOTAL_LIST, is_bold=True, bold_font=bold_font)
current_row = 4 ws.set_row(1, 20)
ws.write(1, 0, "", fmt_empty)
ws.write(1, 1, "", fmt_empty)
# Подготовка флагов для точной фильтрации ws.set_row(2, 20)
ws.write(2, 0, "По списку", fmt_tot_l)
ws.write(2, 1, len(merged_df), fmt_tot_r)
current_row = 3
is_no_pass = merged_df['no_scud_pass'] == True if 'no_scud_pass' in merged_df.columns else False is_no_pass = merged_df['no_scud_pass'] == True if 'no_scud_pass' in merged_df.columns else False
is_exc = merged_df.get('is_excluded', False) == True is_exc = merged_df.get('is_excluded', False) == True
# 3. НЕИЗВЕСТНО (Исключаем категорию "Нет пропуска" и "Исключения ОВК/Охрана") # 1. Неизвестно (Раскрыто по умолчанию)
unexplained = merged_df[ unexplained = merged_df[
(merged_df['Пришел'] == False) & (merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) &
(~is_no_pass) & (~is_no_pass) &
(~is_exc) (~is_exc)
] ]
ws.cell(row=current_row, column=1, value="неизвестно") fmt_unexp_hl = make_fmt(bg_color='#FCE4D6', bold=True, align="left")
ws.cell(row=current_row, column=2, value=len(unexplained)) fmt_unexp_hr = make_fmt(bg_color='#FCE4D6', bold=True, align="right")
format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=True, bold_font=bold_font) fmt_unexp_rl = make_fmt(bg_color='#FCE4D6', bold=False, align="left")
fmt_unexp_rr = make_fmt(bg_color='#FCE4D6', bold=False, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, "неизвестно", fmt_unexp_hl)
ws.write(current_row, 1, len(unexplained), fmt_unexp_hr)
current_row += 1 current_row += 1
for fio in sorted(unexplained['Сотрудник'].dropna().unique()): for fio in sorted(unexplained['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio) ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False})
format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=False) ws.write(current_row, 0, fio, fmt_unexp_rl)
ws.row_dimensions[current_row].outlineLevel = 1 ws.write(current_row, 1, "", fmt_unexp_rr)
ws.row_dimensions[current_row].hidden = False
current_row += 1 current_row += 1
# 4. РАЗДЕЛ: НЕТ ПРОПУСКА (Строго один независимый блок) # 2. Нет пропуска (Раскрыто по умолчанию)
no_pass_df = merged_df[is_no_pass] if 'no_scud_pass' in merged_df.columns else pd.DataFrame() no_pass_df = merged_df[is_no_pass & (~is_exc)] if 'no_scud_pass' in merged_df.columns else pd.DataFrame()
fmt_np_hl = make_fmt(bg_color='#E1F5FE', bold=True, align="left")
fmt_np_hr = make_fmt(bg_color='#E1F5FE', bold=True, align="right")
fmt_np_rl = make_fmt(bg_color='#E1F5FE', bold=False, align="left")
fmt_np_rr = make_fmt(bg_color='#E1F5FE', bold=False, align="right")
ws.cell(row=current_row, column=1, value="Нет пропуска") ws.set_row(current_row, 20)
ws.cell(row=current_row, column=2, value=len(no_pass_df)) ws.write(current_row, 0, "Нет пропуска", fmt_np_hl)
format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=True, bold_font=bold_font) ws.write(current_row, 1, len(no_pass_df), fmt_np_hr)
current_row += 1 current_row += 1
if not no_pass_df.empty: if not no_pass_df.empty:
for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()): for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio) ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False})
format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=False) ws.write(current_row, 0, fio, fmt_np_rl)
ws.row_dimensions[current_row].outlineLevel = 1 ws.write(current_row, 1, "", fmt_np_rr)
ws.row_dimensions[current_row].hidden = False
current_row += 1 current_row += 1
# 5. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True) # 3. Официальные отсутствия
reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower()
is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False)
absent_only = merged_df[ absent_only = merged_df[
(merged_df['Пришел'] == False) & (merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].notna()) & (merged_df['Вид_отсутствия'].notna()) &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
(~is_remote_reason)
] ]
absent_groups = absent_only.groupby('Вид_отсутствия') absent_groups = absent_only.groupby('Вид_отсутствия')
pastels = ['#FFF2CC', '#E1D5E7', '#E1F5FE', '#FFF0F5', '#FCF3CF']
for idx_cat, (cat_name, group) in enumerate(absent_groups):
hex_color = CATEGORY_PASTEL_COLORS[idx_cat % len(CATEGORY_PASTEL_COLORS)]
cat_fill = PatternFill(start_color=hex_color, end_color=hex_color, fill_type="solid")
ws.cell(row=current_row, column=1, value=cat_name) for idx_cat, (cat_name, group) in enumerate(absent_groups):
ws.cell(row=current_row, column=2, value=len(group)) hex_c = pastels[idx_cat % len(pastels)]
format_row_cells(ws, current_row, cat_fill, is_bold=True, bold_font=bold_font) fmt_cat_hl = make_fmt(bg_color=hex_c, bold=True, align="left")
fmt_cat_hr = make_fmt(bg_color=hex_c, bold=True, align="right")
fmt_cat_rl = make_fmt(bg_color=hex_c, bold=False, align="left")
fmt_cat_rr = make_fmt(bg_color=hex_c, bold=False, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, cat_name, fmt_cat_hl)
ws.write(current_row, 1, len(group), fmt_cat_hr)
current_row += 1 current_row += 1
for fio in sorted(group['Сотрудник'].dropna().unique()): is_other_category = (str(cat_name).strip().lower() == "иное")
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, cat_fill, is_bold=False) for _, row in group.sort_values(by='Сотрудник').iterrows():
ws.row_dimensions[current_row].outlineLevel = 1 fio = row.get('Сотрудник', '')
ws.row_dimensions[current_row].hidden = True # Если категория "Иное" — берем детальную причину из manual_absences / detailed_reason
detail_val = row.get('detailed_reason', row.get('comment', '')) if is_other_category else ""
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True})
ws.write(current_row, 0, fio, fmt_cat_rl)
ws.write(current_row, 1, detail_val, fmt_cat_rr)
current_row += 1 current_row += 1
# 6. ИТОГО НА РАБОТЕ (Свернут hidden=True) # 4. Итого на работе (Только общее число, без раскрывающегося списка ФИО. Включает исключения без справок)
is_working_mask = (merged_df['Пришел'] == True) | ( exc_without_doc = merged_df[is_exc & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip().isin(['', 'nan', 'Исключение'])))]
merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False) present_scud = merged_df[(merged_df['Пришел'] == True) & (~is_exc)]
)
present = merged_df[is_working_mask] total_present_count = len(present_scud) + len(exc_without_doc)
ws.cell(row=current_row, column=1, value="Итого на работе") fmt_pres_hl = make_fmt(bg_color='#E2EFDA', bold=True, align="left")
ws.cell(row=current_row, column=2, value=len(present)) fmt_pres_hr = make_fmt(bg_color='#E2EFDA', bold=True, align="right")
format_row_cells(ws, current_row, FILL_PRESENT, is_bold=True, bold_font=bold_font)
ws.set_row(current_row, 20)
ws.write(current_row, 0, "Итого на работе", fmt_pres_hl)
ws.write(current_row, 1, total_present_count, fmt_pres_hr)
current_row += 1 current_row += 1
if not present.empty: # 5. Удаленная работа (Свернуто)
for fio in sorted(present['Сотрудник'].dropna().unique()): remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)]
ws.cell(row=current_row, column=1, value=fio) fmt_rem_hl = make_fmt(bg_color='#E8F8F5', bold=True, align="left")
format_row_cells(ws, current_row, FILL_PRESENT, is_bold=False) fmt_rem_hr = make_fmt(bg_color='#E8F8F5', bold=True, align="right")
ws.row_dimensions[current_row].outlineLevel = 1 fmt_rem_rl = make_fmt(bg_color='#E8F8F5', bold=False, align="left")
ws.row_dimensions[current_row].hidden = True fmt_rem_rr = make_fmt(bg_color='#E8F8F5', bold=False, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, "В том числе на удаленной работе", fmt_rem_hl)
ws.write(current_row, 1, len(remote_home), fmt_rem_hr)
current_row += 1
if not remote_home.empty:
for fio in sorted(remote_home['Сотрудник'].dropna().unique()):
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True})
ws.write(current_row, 0, fio, fmt_rem_rl)
ws.write(current_row, 1, "", fmt_rem_rr)
current_row += 1 current_row += 1
# 7. АНОМАЛИИ СКУД И 1С (ОВК и Подрядчики из исключений СУДА НЕ ПОПАДАЮТ) # 6. Аномалии СКУД и 1С (Свернуто)
anomalies = merged_df[ anomalies = merged_df[
(~is_exc) & ( (~is_exc) & (
((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))) | ((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
(~is_remote_reason) &
(~reason_clean.str.contains('командировк', na=False))) |
(merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY') (merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY')
) )
] ]
fmt_anom_hl = make_fmt(bg_color='#FCE4D6', bold=True, align="left")
ws.cell(row=current_row, column=1, value="Аномалии СКУД и 1С") fmt_anom_hr = make_fmt(bg_color='#FCE4D6', bold=True, align="right")
ws.cell(row=current_row, column=2, value=len(anomalies)) fmt_anom_rl = make_fmt(bg_color='#FCE4D6', bold=False, align="left")
format_row_cells(ws, current_row, FILL_ANOMALY, is_bold=True, bold_font=bold_font) fmt_anom_rr = make_fmt(bg_color='#FCE4D6', bold=False, align="left", wrap=True)
ws.set_row(current_row, 20)
ws.write(current_row, 0, "Аномалии СКУД и 1С", fmt_anom_hl)
ws.write(current_row, 1, len(anomalies), fmt_anom_hr)
current_row += 1 current_row += 1
chars_per_line_b = 30 chars_per_line_b = 30
if not anomalies.empty: if not anomalies.empty:
for _, row in anomalies.iterrows(): for _, row in anomalies.iterrows():
fio = row.get('Сотрудник', '') fio = row.get('Сотрудник', '')
@@ -240,162 +316,194 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
reason_text = f"🚨 АНОМАЛИЯ СКУД: Нет входа (первая активность: {first_act})" reason_text = f"🚨 АНОМАЛИЯ СКУД: Нет входа (первая активность: {first_act})"
else: else:
reason_text = f"В 1С: {reason}" reason_text = f"В 1С: {reason}"
cell_a = ws.cell(row=current_row, column=1, value=f"{fio}")
cell_b = ws.cell(row=current_row, column=2, value=reason_text)
cell_a.fill = FILL_ANOMALY
cell_b.fill = FILL_ANOMALY
apply_borders_to_cell(cell_a)
apply_borders_to_cell(cell_b)
cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
cell_a.alignment = Alignment(horizontal="left", vertical="center")
if len(reason_text) > chars_per_line_b:
lines_count = math.ceil(len(reason_text) / chars_per_line_b)
ws.row_dimensions[current_row].height = max(lines_count * 18, 22)
else:
ws.row_dimensions[current_row].height = 20
ws.row_dimensions[current_row].outlineLevel = 1 lines_count = math.ceil(len(reason_text) / chars_per_line_b) if len(reason_text) > chars_per_line_b else 1
ws.row_dimensions[current_row].hidden = True row_h = max(lines_count * 18, 20)
ws.set_row(current_row, row_h, None, {'level': 1, 'hidden': True, 'collapsed': True})
ws.write(current_row, 0, fio, fmt_anom_rl)
ws.write(current_row, 1, reason_text, fmt_anom_rr)
current_row += 1 current_row += 1
ws.column_dimensions['A'].width = 45.0 ws.set_column(0, 0, 45)
ws.column_dimensions['B'].width = 38.0 ws.set_column(1, 1, 38)
try: safe_close_workbook(wb, output_path, target_dir, filename)
wb.save(output_path)
print(f"[✓] Ежедневная сводка сохранена: {output_path}")
except PermissionError:
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
alt_path = os.path.join(OUTPUT_DIR, alt_filename)
wb.save(alt_path)
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА --- # =============================================================================
def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None): # 2. ДЕТАЛЬНЫЙ СУТОЧНЫЙ ОТЧЕТ ЗА ВЧЕРА
# =============================================================================
def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
date_clean = str(date_str).replace('_', '.')
if not filename: if not filename:
filename = f"{format_date_ru(date_str)} отчет.xlsx" filename = f"{format_date_ru(date_clean)} отчет.xlsx"
output_path = os.path.join(OUTPUT_DIR, filename) if merged_df is not None and not merged_df.empty:
wb = Workbook() df_export = merged_df[merged_df.get('is_excluded', False) == False].copy()
ws = wb.active else:
ws.title = "Детальный_отчет" df_export = pd.DataFrame()
ws["B2"] = "Дата:" target_dir = get_dated_reports_dir(date_clean)
ws["D2"] = date_str output_path = os.path.join(target_dir, filename)
ws["B2"].font = Font(name="Arial", size=10, bold=True)
ws["D2"].font = Font(name="Arial", size=10, bold=True) wb = xlsxwriter.Workbook(output_path)
ws = wb.add_worksheet("Детальный_отчет")
def make_fmt(bg_color=None, bold=False, align="left", wrap=False):
d = {
'font_name': 'Arial',
'font_size': 10,
'bold': bold,
'align': align,
'valign': 'vcenter',
'border': 1,
'border_color': '#D3D3D3',
'text_wrap': wrap
}
if bg_color:
d['bg_color'] = bg_color
return wb.add_format(d)
fmt_date_lbl = wb.add_format({'font_name': 'Arial', 'font_size': 10, 'bold': True})
ws.write(1, 1, "Дата:", fmt_date_lbl)
ws.write(1, 3, date_clean, fmt_date_lbl)
headers = [ headers = [
"№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода", "№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода",
"находился в здании", "причина отсутствия", "норма", "отклонение от нормы" "находился в здании", "причина отсутствия", "норма", "отклонение от нормы"
] ]
ws.append([]) fmt_hdr = make_fmt(bg_color='#D9E1F2', bold=True, align="center", wrap=True)
ws.append(headers) ws.set_row(3, 26)
for col_idx, h_text in enumerate(headers):
ws.write(3, col_idx, h_text, fmt_hdr)
header_fill = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") start_col = 'Начало дня' if 'Начало дня' in df_export.columns else 'Начало_дня'
for col_idx in range(1, len(headers) + 1): end_col = 'Конец дня' if 'Конец дня' in df_export.columns else 'Конец_дня'
cell = ws.cell(row=4, column=col_idx) hours_col = 'Часы' if 'Часы' in df_export.columns else 'Находился_в_здании'
cell.fill = header_fill
cell.font = Font(name="Arial", size=10, bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
apply_borders_to_cell(cell)
start_col = 'Начало дня' if 'Начало дня' in merged_df.columns else 'Начало_дня'
end_col = 'Конец дня' if 'Конец дня' in merged_df.columns else 'Конец_дня'
hours_col = 'Часы' if 'Часы' in merged_df.columns else 'Находился_в_здании'
chars_per_line_h = 24 chars_per_line_h = 24
for idx, row in merged_df.reset_index(drop=True).iterrows(): for idx, row in df_export.reset_index(drop=True).iterrows():
row_num = 4 + idx
is_present = row.get('Пришел', False) is_present = row.get('Пришел', False)
absence_reason = row.get('Вид_отсутствия', '') absence_reason = row.get('Вид_отсутствия', '')
has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != '' has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != ''
anom_flag = row.get('anomaly_flag', 'NONE')
in_val = str(row.get(start_col, 'Нет входа')).strip()
out_val = str(row.get(end_col, 'Нет выхода')).strip()
in_building_str = str(row.get(hours_col, '00:00')) in_building_str = str(row.get(hours_col, '00:00'))
first_act_val = str(row.get('Первая_активность', '—')).strip() first_act_val = str(row.get('Первая_активность', '—')).strip()
has_first_act = first_act_val not in ['—', '', 'None', 'nan'] has_first_act = first_act_val not in ['—', '', 'None', 'nan']
dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', ''))) # Автозакрытие отключено по согласованию с ОК: сохраняем факт отсутствия выхода
deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30) deviation_val = calculate_deviation(
in_building_str,
reason=absence_reason if has_reason else "",
norm_hours=8,
lunch_minutes=30
)
ws.append([ dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', '')))
idx + 1,
row.get('Сотрудник', ''), row_color = None
dept_scud_val,
row.get(start_col, 'Нет входа'),
first_act_val,
row.get(end_col, 'Нет выхода'),
in_building_str,
absence_reason if has_reason else '',
8,
deviation_val
])
row_num = 5 + idx
# ЗАЛИВКА СТРОК:
if is_present and has_reason: if is_present and has_reason:
row_fill = GREEN_FILL row_color = '#E2EFDA'
elif not is_present and has_reason: elif not is_present and has_reason:
row_fill = YELLOW_FILL row_color = '#FFF2CC'
elif not is_present and not has_reason and not has_first_act: elif not is_present and not has_reason and not has_first_act:
# Розово-красный подсвечивает исключительно неизвестные случаи (потенциальные прогулы) row_color = '#FCE4D6'
row_fill = LIGHT_RED_FILL
else:
row_fill = None
val_h_str = str(absence_reason) if has_reason else "" val_h_str = str(absence_reason) if has_reason else ""
if len(val_h_str) > chars_per_line_h: lines_count = math.ceil(len(val_h_str) / chars_per_line_h) if len(val_h_str) > chars_per_line_h else 1
needed_lines = math.ceil(len(val_h_str) / chars_per_line_h) ws.set_row(row_num, max(lines_count * 18, 20))
ws.row_dimensions[row_num].height = max(needed_lines * 18, 22)
else:
ws.row_dimensions[row_num].height = 20
for col_idx in range(1, len(headers) + 1): values = [
cell = ws.cell(row=row_num, column=col_idx) (idx + 1, 'center', False),
apply_borders_to_cell(cell) (row.get('Сотрудник', ''), 'left', False),
if row_fill: (dept_scud_val, 'center', False),
cell.fill = row_fill (in_val, 'center', False),
(first_act_val, 'center', False),
if col_idx == 8: (out_val, 'center', False),
cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) (in_building_str, 'center', False),
elif col_idx in [1, 4, 5, 6, 7, 9, 10]: (absence_reason if has_reason else '', 'left', True),
cell.alignment = Alignment(horizontal="center", vertical="center") (8, 'center', False),
else: (deviation_val, 'center', False)
cell.alignment = Alignment(horizontal="left", vertical="center") ]
# Динамический компактный автоподгон ширины колонок for col_idx, (val, align_type, is_wrap) in enumerate(values):
for col in ws.columns: fmt = make_fmt(bg_color=row_color, align=align_type, wrap=is_wrap)
col_letter = get_column_letter(col[0].column) ws.write(row_num, col_idx, val, fmt)
max_len = 0
for cell in col:
if cell.value is not None:
cell_lines = str(cell.value).split("\n")
line_max = max(len(line) for line in cell_lines)
if line_max > max_len:
max_len = line_max
optimal_width = max(max_len + 2, 8) col_widths = {
if optimal_width > 35: 0: 4,
optimal_width = 35 1: 33,
ws.column_dimensions[col_letter].width = optimal_width 2: 13,
3: 11,
4: 11,
5: 11,
6: 12,
7: 24,
8: 6,
9: 11
}
for col_idx, width in col_widths.items():
ws.set_column(col_idx, col_idx, width)
try: safe_close_workbook(wb, output_path, target_dir, filename)
wb.save(output_path)
print(f"[✓] Детальный отчет сохранен: {output_path}")
except PermissionError:
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
alt_path = os.path.join(OUTPUT_DIR, alt_filename)
wb.save(alt_path)
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
# =============================================================================
# 3. СЫРОЙ СКУД
# =============================================================================
def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"): def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"):
output_path = os.path.join(OUTPUT_DIR, filename) output_path = os.path.join(REPORTS_DIR, filename)
df_scud.to_excel(output_path, index=False) target_dir = os.path.dirname(output_path)
wb = xlsxwriter.Workbook(output_path)
ws = wb.add_worksheet("Сырые_данные")
fmt_hdr = wb.add_format({
'font_name': 'Calibri',
'font_size': 11,
'bold': True,
'bg_color': '#D9E1F2',
'border': 1,
'border_color': '#D3D3D3',
'align': 'center',
'valign': 'vcenter'
})
fmt_cell = wb.add_format({
'font_name': 'Calibri',
'font_size': 11,
'border': 1,
'border_color': '#D3D3D3',
'valign': 'vcenter',
'align': 'left'
})
headers = list(df_scud.columns)
ws.set_row(3, 28)
for col_idx, header in enumerate(headers):
ws.write(0, col_idx, str(header), fmt_hdr)
col_widths = [len(str(h)) for h in headers]
for row_idx, row_values in enumerate(df_scud.values, start=1):
ws.set_row(row_idx, 19)
for col_idx, val in enumerate(row_values):
if pd.isna(val) or val is None:
val_str = ""
elif isinstance(val, bool):
val_str = "Да" if val else "Нет"
else:
val_str = str(val)
ws.write(row_idx, col_idx, val_str, fmt_cell)
if len(val_str) > col_widths[col_idx]:
col_widths[col_idx] = len(val_str)
for col_idx, width in enumerate(col_widths):
ws.set_column(col_idx, col_idx, min(max(width + 3, 10), 45))
safe_close_workbook(wb, output_path, target_dir, filename)
+92
View File
@@ -0,0 +1,92 @@
"""
===============================================================================
FILE: services/exceptions_repo.py
ROLE: Управление исключениями в SQLite с синхронизацией с exceptions.json.
===============================================================================
"""
from typing import Dict, List, Any
import json
import os
from config import EXCEPTIONS_PATH, normalize_fio
from core.connection import get_connection
def init_exceptions_table():
with get_connection() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS exceptions_registry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
value TEXT NOT NULL,
comment TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(category, value)
);
""")
conn.commit()
def get_all_exceptions_from_db() -> Dict[str, List[str]]:
init_exceptions_table()
cfg = {
"departments": [],
"positions": [],
"fio": [],
"position_keywords": [],
"include_fio": [],
"turnstile_fio": [],
"turnstile_departments": []
}
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT category, value FROM exceptions_registry")
rows = cursor.fetchall()
if not rows and os.path.exists(EXCEPTIONS_PATH):
sync_json_to_db()
return get_all_exceptions_from_db()
for cat, val in rows:
if cat in cfg:
cfg[cat].append(val)
return cfg
def add_exception_to_db(category: str, value: str, comment: str = "") -> bool:
init_exceptions_table()
# ФИО очищаем и приводим к нормализованному виду
val_clean = normalize_fio(value) if category in ["fio", "include_fio", "turnstile_fio"] else value.strip()
if not val_clean:
return False
with get_connection() as conn:
conn.execute(
"INSERT OR REPLACE INTO exceptions_registry (category, value, comment) VALUES (?, ?, ?)",
(category, val_clean, comment)
)
conn.commit()
return True
def remove_exception_from_db(category: str, value: str) -> bool:
init_exceptions_table()
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM exceptions_registry WHERE category = ? AND value = ?", (category, value.strip()))
conn.commit()
return cursor.rowcount > 0
def sync_json_to_db():
"""Переносит данные из exceptions.json в SQLite."""
if not os.path.exists(EXCEPTIONS_PATH):
return
try:
with open(EXCEPTIONS_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
for cat, items in data.items():
for item in items:
add_exception_to_db(cat, item, comment="Импорт из JSON")
except Exception as e:
print(f"[⚠️] Ошибка синхронизации JSON -> DB: {e}")
View File
+37
View File
@@ -0,0 +1,37 @@
"""
===============================================================================
FILE: services/knowledge/service.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / knowledge
ROLE: Доменный сервис базы знаний, правил компании и синонимов подразделений.
===============================================================================
"""
from typing import List, Dict, Any
from core.repositories.zup_repo import (
get_all_rules_from_db,
add_rule_to_db,
get_department_synonyms_dict,
add_department_synonym_to_db
)
def get_rules() -> List[Dict[str, Any]]:
"""Получить все правила базы знаний в виде списка словарей."""
raw_rules = get_all_rules_from_db()
return [{"id": idx, "rule_text": r} for idx, r in enumerate(raw_rules, 1)]
def add_rule(rule_text: str, added_by: str = "Human") -> None:
"""Добавить новое правило в базу знаний."""
add_rule_to_db(rule_text, added_by=added_by)
def get_synonyms() -> Dict[str, str]:
"""Получить словарь синонимов отделов."""
return get_department_synonyms_dict()
def register_department_synonym(short_name: str, full_name: str) -> None:
"""Сохранить новую пару синонимов подразделения."""
add_department_synonym_to_db(short_name, full_name)
+214
View File
@@ -0,0 +1,214 @@
"""
===============================================================================
FILE: services/manual_absences_repo.py
ROLE: Репозиторий ручных реестров ("Мест. командир.", "Иное") и поиск по штату 1С.
===============================================================================
"""
import os
import csv
from datetime import datetime
from typing import List, Dict, Any, Optional
from core.connection import get_connection
from config import normalize_fio, DATA_DIR
REASONS_CSV_PATH = os.path.join(DATA_DIR, "static_reason_absence.csv")
def init_manual_absences_table() -> None:
with get_connection() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS manual_absences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
absence_type TEXT NOT NULL, -- 'LOCAL_TRIP' или 'OTHER'
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
department TEXT DEFAULT '',
position TEXT DEFAULT '',
date_start TEXT, -- 'YYYY-MM-DD'
date_end TEXT, -- 'YYYY-MM-DD'
reason TEXT NOT NULL,
comment TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_abs_dates ON manual_absences(date_start, date_end);")
conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_abs_fio ON manual_absences(fio_clean);")
conn.commit()
def get_static_reasons() -> List[str]:
"""Возвращает список причин из data/static_reason_absence.csv."""
if not os.path.exists(REASONS_CSV_PATH):
# Если файл еще не создан, создаем базовый набор причин
os.makedirs(os.path.dirname(REASONS_CSV_PATH), exist_ok=True)
default_reasons = ["По семейным обстоятельствам", "Медосмотр", "Сдача крови", "Учебный отпуск", "Административный отпуск"]
with open(REASONS_CSV_PATH, "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(["reason"])
for r in default_reasons:
writer.writerow([r])
return default_reasons
reasons = []
try:
with open(REASONS_CSV_PATH, "r", encoding="utf-8") as f:
reader = csv.reader(f)
for row in reader:
if row and row[0].strip() and row[0].strip().lower() != "reason":
reasons.append(row[0].strip())
except Exception:
pass
return reasons
def search_staff_suggestions(query: str, limit: int = 15) -> List[Dict[str, str]]:
"""Живой поиск сотрудников по zup_staff для автокомплита."""
q = (query or "").strip()
if not q or len(q) < 2:
return []
# Приводим к разным регистрам для гарантированного поиска кириллицы в SQLite
q_lower = q.lower()
q_title = q.capitalize()
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
# 1. Находим действительно самый свежий срез штата (по created_at или по структуре даты ГГГГ-ММ-ДД)
cursor.execute("""
SELECT snapshot_date
FROM zup_staff
ORDER BY
SUBSTR(snapshot_date, 7, 4) DESC,
SUBSTR(snapshot_date, 4, 2) DESC,
SUBSTR(snapshot_date, 1, 2) DESC,
id DESC
LIMIT 1
""")
row = cursor.fetchone()
latest_date = row[0] if row else None
if not latest_date:
return []
# 2. Поиск с сортировкой: сначала те, у кого фамилия НАЧИНАЕТСЯ с запроса
sql = """
SELECT DISTINCT fio, fio_clean, department, position
FROM zup_staff
WHERE snapshot_date = ?
AND (
fio LIKE ? OR fio LIKE ? OR fio_clean LIKE ? OR fio_clean LIKE ?
OR fio LIKE ? OR fio_clean LIKE ?
)
ORDER BY
CASE
WHEN fio LIKE ? OR fio_clean LIKE ? THEN 0
ELSE 1
END,
fio ASC
LIMIT ?
"""
prefix_pattern_title = f"{q_title}%"
prefix_pattern_lower = f"{q_lower}%"
any_pattern_title = f"%{q_title}%"
any_pattern_lower = f"%{q_lower}%"
cursor.execute(sql, (
latest_date,
prefix_pattern_title, prefix_pattern_lower, prefix_pattern_title, prefix_pattern_lower,
any_pattern_title, any_pattern_lower,
prefix_pattern_title, prefix_pattern_title,
limit
))
rows = cursor.fetchall()
return [
{
"fio": r["fio"],
"fio_clean": r["fio_clean"],
"department": r["department"] or "—",
"position": r["position"] or "—"
}
for r in rows
]
def add_manual_absence(
absence_type: str,
fio: str,
reason: str,
department: str = "",
position: str = "",
date_start: Optional[str] = None,
date_end: Optional[str] = None,
comment: str = ""
) -> int:
init_manual_absences_table()
clean_fio = normalize_fio(fio)
if not clean_fio:
return 0
today_str = datetime.now().strftime("%Y-%m-%d")
d_start = date_start.strip() if date_start and date_start.strip() else today_str
d_end = date_end.strip() if date_end and date_end.strip() else today_str
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO manual_absences (
absence_type, fio, fio_clean, department, position,
date_start, date_end, reason, comment
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (absence_type.upper(), fio.strip(), clean_fio, department.strip(), position.strip(), d_start, d_end, reason.strip(), comment.strip()))
conn.commit()
return cursor.lastrowid
def delete_manual_absence(item_id: int) -> bool:
init_manual_absences_table()
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM manual_absences WHERE id = ?", (item_id,))
conn.commit()
return cursor.rowcount > 0
def get_manual_absences_list(absence_type: Optional[str] = None) -> List[Dict[str, Any]]:
init_manual_absences_table()
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
if absence_type:
cursor.execute("""
SELECT id, absence_type, fio, fio_clean, department, position, date_start, date_end, reason, comment, created_at
FROM manual_absences
WHERE absence_type = ?
ORDER BY id DESC
""", (absence_type.upper(),))
else:
cursor.execute("""
SELECT id, absence_type, fio, fio_clean, department, position, date_start, date_end, reason, comment, created_at
FROM manual_absences
ORDER BY id DESC
""")
return [dict(r) for r in cursor.fetchall()]
def get_active_manual_absences_for_date(date_str: str) -> List[Dict[str, Any]]:
"""Выбирает записи, активные на дату отчета (формат даты ДД.ММ.ГГГГ)."""
init_manual_absences_table()
try:
dt_target = datetime.strptime(date_str.replace('_', '.'), "%d.%m.%Y").strftime("%Y-%m-%d")
except Exception:
dt_target = datetime.now().strftime("%Y-%m-%d")
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT id, absence_type, fio, fio_clean, department, position, date_start, date_end, reason
FROM manual_absences
WHERE (date_start IS NULL OR date_start <= ?)
AND (date_end IS NULL OR date_end >= ?)
""", (dt_target, dt_target))
return [dict(r) for r in cursor.fetchall()]
View File
+79
View File
@@ -0,0 +1,79 @@
"""
===============================================================================
FILE: services/prompts/diff_engine.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / prompts
ROLE: Формирование превью изменений промпта и генерация визуального HTML-Diff.
AI-CONTEXT-ANCHORS:
- ANCHOR[PROMPT_DIFF_BUILDER]: Генерация черновика и HTML-разметки изменений.
===============================================================================
"""
from typing import Tuple, Dict, List, Set
from core.connection import get_connection
def build_prompt_diff(
action: str,
section_id: int = None,
item_id: int = None,
content: str = "",
delete_nodes: List[Tuple[int, int]] = None,
prompt_name: str = "main_agent"
) -> Tuple[str, str]:
act = (action or "ADD").upper()
nodes_to_delete: Set[Tuple[int, int]] = set()
if delete_nodes:
nodes_to_delete.update(delete_nodes)
elif act == "DELETE" and section_id is not None and item_id is not None:
nodes_to_delete.add((section_id, item_id))
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT section_id, item_id, content
FROM system_prompt_nodes
WHERE prompt_name = ? AND is_active = 1
ORDER BY section_id, item_id
""", (prompt_name,))
existing_nodes = cursor.fetchall()
nodes_dict = {(r["section_id"], r["item_id"]): r["content"] for r in existing_nodes}
nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k not in nodes_to_delete}
if act not in ["DELETE", "BATCH_DELETE"] and section_id is not None and item_id is not None:
nodes_dict_for_draft[(section_id, item_id)] = content
draft_lines = []
curr_sec = None
for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()):
if i_id == 0:
if curr_sec is not None: draft_lines.append("")
draft_lines.append(f"{s_id}. {txt}")
curr_sec = s_id
else:
draft_lines.append(f" {s_id}.{i_id}. {txt}")
merged_prompt = "\n".join(draft_lines)
diff_lines = []
curr_sec = None
display_nodes = dict(nodes_dict)
if act not in ["DELETE", "BATCH_DELETE"] and section_id is not None and item_id is not None:
display_nodes[(section_id, item_id)] = content
for (s_id, i_id), txt in sorted(display_nodes.items()):
if i_id == 0:
if curr_sec is not None: diff_lines.append("")
diff_lines.append(f"{s_id}. {txt}")
curr_sec = s_id
else:
if (s_id, i_id) in nodes_to_delete:
line_str = f' <span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{s_id}.{i_id}. {txt} [УДАЛЕНИЕ]</span>'
elif act == "ADD" and s_id == section_id and i_id == item_id:
line_str = f' <span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{s_id}.{i_id}. {txt}</span>'
else:
line_str = f" {s_id}.{i_id}. {txt}"
diff_lines.append(line_str)
return merged_prompt, "\n".join(diff_lines)
+114
View File
@@ -0,0 +1,114 @@
"""
===============================================================================
FILE: services/prompts/repository.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / prompts
ROLE: Реляционное хранилище узлов системного промпта (таблица system_prompt_nodes).
AI-CONTEXT-ANCHORS:
- ANCHOR[PROMPT_REPO_GET_ACTIVE]: Сборка активного промпта из узлов БД.
- ANCHOR[PROMPT_REPO_APPLY_ACTION]: Точечная вставка / изменение / удаление узла.
===============================================================================
"""
import re
import logging
from typing import List, Tuple, Dict, Any
from core.connection import get_connection
logger = logging.getLogger("PROMPT_REPO")
# ANCHOR[PROMPT_REPO_GET_ACTIVE]
def repo_get_active_prompt(prompt_name: str = "main_agent") -> str:
"""Собирает структурированный текст системного промпта из активных узлов."""
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT section_id, item_id, content
FROM system_prompt_nodes
WHERE prompt_name = ? AND is_active = 1
ORDER BY section_id ASC, item_id ASC
""", (prompt_name,))
rows = cursor.fetchall()
if not rows:
return "Ты — ИИ-ассистент SCUD Orion AI."
lines = []
current_section = None
for r in rows:
sec_id = r["section_id"]
itm_id = r["item_id"]
content = r["content"]
if itm_id == 0:
if current_section is not None:
lines.append("")
lines.append(f"{sec_id}. {content}")
current_section = sec_id
else:
lines.append(f" {sec_id}.{itm_id}. {content}")
return "\n".join(lines)
# ANCHOR[PROMPT_REPO_APPLY_ACTION]
def repo_apply_prompt_action(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent") -> None:
"""Точечно применяет действие (ADD / UPDATE / DELETE) над узлом промпта."""
with get_connection() as conn:
cursor = conn.cursor()
action_clean = (action or "").upper()
if action_clean in ["ADD", "UPDATE", "EDIT"]:
cursor.execute("""
INSERT INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active, updated_at)
VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(prompt_name, section_id, item_id) DO UPDATE SET
content = excluded.content,
is_active = 1,
updated_at = CURRENT_TIMESTAMP
""", (prompt_name, section_id, item_id, content))
elif action_clean == "DELETE":
cursor.execute("""
DELETE FROM system_prompt_nodes
WHERE prompt_name = ? AND section_id = ? AND item_id = ?
""", (prompt_name, section_id, item_id))
conn.commit()
def repo_save_full_prompt(prompt_text: str, prompt_name: str = "main_agent") -> None:
"""Парсит и полностью перезаписывает все узлы промпта из сырого текста."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM system_prompt_nodes WHERE prompt_name = ?", (prompt_name,))
current_sec = 1
current_itm = 0
for raw_line in prompt_text.splitlines():
clean_line = re.sub(r'<[^>]+>', '', raw_line).strip()
if not clean_line:
continue
sub_match = re.match(r'^(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)$', clean_line)
sec_match = re.match(r'^(\d+)[\.\s\:\-]+(.*)$', clean_line)
if sub_match:
current_sec = int(sub_match.group(1))
current_itm = int(sub_match.group(2))
content = sub_match.group(3).strip()
elif sec_match and not any(c.islower() for c in sec_match.group(2)[:15]):
current_sec = int(sec_match.group(1))
current_itm = 0
content = sec_match.group(2).strip()
else:
current_itm += 1
content = clean_line
cursor.execute("""
INSERT OR REPLACE INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active)
VALUES (?, ?, ?, ?, 1)
""", (prompt_name, current_sec, current_itm, content))
conn.commit()
+49
View File
@@ -0,0 +1,49 @@
"""
===============================================================================
FILE: services/prompts/service.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / prompts
ROLE: Единый доменный сервис управления системным промптом.
===============================================================================
"""
from typing import Tuple, Dict, Any, List, Optional
from .repository import repo_get_active_prompt, repo_apply_prompt_action, repo_save_full_prompt
from .diff_engine import build_prompt_diff
def get_active_system_prompt() -> str:
"""Получить текущий активный системный промпт."""
return repo_get_active_prompt()
def apply_prompt_action(action: str, section_id: int, item_id: int, content: str = "") -> None:
"""Применить точечное изменение к узлу промпта."""
repo_apply_prompt_action(action, section_id, item_id, content)
def save_full_prompt_draft(draft_text: str, prompt_name: str = "main_agent") -> None:
"""Сохранить полный черновик промпта."""
repo_save_full_prompt(draft_text, prompt_name=prompt_name)
def create_prompt_preview(
action: str,
section_id: Optional[int] = None,
item_id: Optional[int] = None,
content: str = "",
delete_nodes: Optional[List[Tuple[int, int]]] = None
) -> Tuple[str, str, str]:
"""
Формирует черновик и diff.
Возвращает (merged_draft, diff_html, baseline_prompt).
"""
baseline = repo_get_active_prompt()
draft, diff_html = build_prompt_diff(
action=action,
section_id=section_id,
item_id=item_id,
content=content,
delete_nodes=delete_nodes
)
return draft, diff_html, baseline
View File
+71
View File
@@ -0,0 +1,71 @@
"""
===============================================================================
FILE: services/scud_etl/anomaly_detector.py
ROLE: Детектирование аномалий СКУД, дубликатов пропусков и несоответствий с 1С.
===============================================================================
"""
import logging
from typing import List, Dict, Any
import pandas as pd
from config import normalize_fio
logger = logging.getLogger("SCUD_ANOMALY")
def detect_registry_anomalies(df_merged: pd.DataFrame, df_raw_scud: pd.DataFrame = None) -> List[Dict[str, Any]]:
anomalies = []
if df_merged is None or df_merged.empty:
return anomalies
# 1. ⭐️ Поиск дубликатов учеток/пропусков в сыром СКУД
if df_raw_scud is not None and not df_raw_scud.empty:
df_raw = df_raw_scud.copy()
if 'fio_clean' not in df_raw.columns:
f_col = 'Сотрудник' if 'Сотрудник' in df_raw.columns else 'ФИО'
df_raw['fio_clean'] = df_raw[f_col].apply(normalize_fio)
counts = df_raw['fio_clean'].value_counts()
duplicate_fios = counts[counts > 1].index.tolist()
for dup_fio in duplicate_fios:
sub = df_raw[df_raw['fio_clean'] == dup_fio]
departments = ", ".join(sub['Подразделение'].astype(str).unique())
anomalies.append({
"fio": dup_fio,
"type": "DUPLICATE_SCUD_CARD",
"description": f"Сотрудник заведен в СКУД {len(sub)} раза (отделы: {departments}). События входа и выхода объединены автоматически."
})
# 2. Поиск кадровых аномалий и сбоев оборудования
for _, row in df_merged.iterrows():
if row.get("is_excluded", False):
continue
fio = row.get("fio_clean") or row.get("Сотрудник", "")
start_day = str(row.get("Начало_дня", "")).strip()
end_day = str(row.get("Конец_дня", "")).strip()
reason = str(row.get("причина отсутствия", row.get("Вид_отсутствия", ""))).strip()
reason_lower = reason.lower()
if "исключен" in reason_lower or "овк" in reason_lower:
continue
# Приход в офис во время отпуска/больничного (удаленка и командировки разрешены)
if start_day not in ["Нет входа", "—", "", "nan", "None"] and reason and reason != "nan":
if not ("удален" in reason_lower or "дистанцион" in reason_lower or "командировк" in reason_lower or "поездк" in reason_lower):
anomalies.append({
"fio": fio,
"type": "PHYSICAL_PRESENCE_DURING_ABSENCE",
"description": f"Присутствовал в здании ({start_day}), но в 1С числится документ: '{reason}'."
})
# Ошибка считывателя (есть выход без входа)
if start_day in ["Нет входа", "—", ""] and end_day not in ["Нет выхода", "—", "", "nan", "None"]:
anomalies.append({
"fio": fio,
"type": "SCUD_EQUIPMENT_ANOMALY",
"description": f"Зафиксирован выход ({end_day}) при отсутствии отметки утреннего входа."
})
return anomalies
+276
View File
@@ -0,0 +1,276 @@
"""
===============================================================================
FILE: services/scud_etl/merger.py
ROLE: Агрегация реестров, выбор совместителей 1С по отделу СКУД и авто-связки.
Распределение исключений в общий рабочий пул при отсутствии справок.
===============================================================================
"""
import logging
from typing import Dict, Any, List
import pandas as pd
from services.knowledge.service import get_department_synonyms_dict
from config import normalize_fio, load_exceptions
from core.connection import get_connection
logger = logging.getLogger("SCUD_MERGER")
def load_identity_mappings() -> Dict[str, str]:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS person_identity_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scud_fio TEXT NOT NULL,
zup_fio TEXT NOT NULL,
scud_dept TEXT,
zup_dept TEXT,
match_source TEXT DEFAULT 'AI',
status TEXT DEFAULT 'ACTIVE',
confidence REAL DEFAULT 1.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(scud_fio, zup_fio)
);
""")
cursor.execute("""
SELECT scud_fio, zup_fio
FROM person_identity_mapping
WHERE status = 'ACTIVE'
""")
return {r[0]: r[1] for r in cursor.fetchall()}
def aggregate_scud_by_person(df_scud: pd.DataFrame) -> pd.DataFrame:
if df_scud is None or df_scud.empty:
return df_scud
df = df_scud.copy()
if 'fio_clean' not in df.columns:
fio_col = 'Сотрудник' if 'Сотрудник' in df.columns else 'ФИО'
df['fio_clean'] = df[fio_col].apply(normalize_fio)
mapping_dict = load_identity_mappings()
if mapping_dict:
df['fio_clean'] = df['fio_clean'].apply(lambda f: mapping_dict.get(f, f))
aggregated_rows = []
for fio_clean, group in df.groupby('fio_clean'):
if len(group) == 1:
aggregated_rows.append(group.iloc[0].to_dict())
continue
base_row = group.sort_values(by='Пришел', ascending=False).iloc[0].to_dict()
valid_ins = [
str(t).strip() for t in group['Начало_дня']
if str(t).strip() not in ['Нет входа', '—', '', 'nan', 'None', '00:00:00', '00:00']
]
base_row['Начало_дня'] = min(valid_ins) if valid_ins else 'Нет входа'
valid_outs = [
str(t).strip() for t in group['Конец_дня']
if str(t).strip() not in ['Нет выхода', '—', '', 'nan', 'None', '00:00:00', '00:00']
]
base_row['Конец_дня'] = max(valid_outs) if valid_outs else 'Нет выхода'
valid_acts = [
str(t).strip() for t in group['Первая_активность']
if str(t).strip() not in ['—', '', 'nan', 'None', '00:00:00']
]
base_row['Первая_активность'] = min(valid_acts) if valid_acts else '—'
base_row['Пришел'] = any(group['Пришел'] == True) or (base_row['Начало_дня'] != 'Нет входа')
durations = [str(d) for d in group['Находился_в_здании'] if str(d) not in ['00:00', '', 'nan']]
if durations:
base_row['Находился_в_здании'] = max(durations)
aggregated_rows.append(base_row)
return pd.DataFrame(aggregated_rows)
def select_best_zup_position(df_staff_1c: pd.DataFrame, df_scud_agg: pd.DataFrame) -> pd.DataFrame:
if df_staff_1c is None or df_staff_1c.empty:
return pd.DataFrame()
df_staff = df_staff_1c.copy()
if 'fio_clean' not in df_staff.columns:
f_col = 'ФИО' if 'ФИО' in df_staff.columns else 'Сотрудник'
df_staff['fio_clean'] = df_staff[f_col].apply(normalize_fio)
scud_dept_map = {}
if df_scud_agg is not None and not df_scud_agg.empty:
for _, r in df_scud_agg.iterrows():
scud_dept_map[r.get('fio_clean', '')] = str(r.get('Подразделение', '')).strip().upper()
best_rows = []
for fio, group in df_staff.groupby('fio_clean'):
if len(group) == 1:
best_rows.append(group.iloc[0].to_dict())
continue
target_scud_dept = scud_dept_map.get(fio, "")
matched_row = None
if target_scud_dept:
for _, r in group.iterrows():
dept_1c = str(r.get('Подразделение', '')).strip().upper()
if dept_1c == target_scud_dept or target_scud_dept in dept_1c or dept_1c in target_scud_dept:
matched_row = r.to_dict()
break
if not matched_row:
matched_row = group.iloc[0].to_dict()
best_rows.append(matched_row)
return pd.DataFrame(best_rows)
def merge_scud_and_1c(
df_scud: pd.DataFrame,
df_staff_1c: pd.DataFrame,
df_absences_1c: pd.DataFrame
) -> pd.DataFrame:
if (df_scud is None or df_scud.empty) and (df_staff_1c is None or df_staff_1c.empty):
return pd.DataFrame(columns=[
'Сотрудник', 'fio_clean', 'Подразделение', 'Должность',
'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании',
'Пришел', 'anomaly_flag', 'причина отсутствия', 'Вид_отсутствия', 'is_excluded'
])
synonyms = get_department_synonyms_dict()
exceptions_cfg = load_exceptions()
df_scud_agg = aggregate_scud_by_person(df_scud)
df_staff_agg = select_best_zup_position(df_staff_1c, df_scud_agg)
df_res = df_scud_agg.copy() if df_scud_agg is not None and not df_scud_agg.empty else df_staff_agg.copy()
if "Сотрудник" in df_res.columns:
df_res["fio_clean"] = df_res["Сотрудник"].apply(normalize_fio)
elif "ФИО" in df_res.columns:
df_res["Сотрудник"] = df_res["ФИО"]
df_res["fio_clean"] = df_res["ФИО"].apply(normalize_fio)
elif "fio_clean" not in df_res.columns:
df_res["fio_clean"] = ""
for col, default_val in [
('Начало_дня', 'Нет входа'),
('Первая_активность', '—'),
('Конец_дня', 'Нет выхода'),
('Находился_в_здании', '00:00'),
('Пришел', False),
('anomaly_flag', 'NONE')
]:
if col not in df_res.columns:
df_res[col] = default_val
reverse_synonyms = {v.lower(): k.upper() for k, v in synonyms.items()}
direct_synonyms = {k.lower(): k.upper() for k in synonyms.keys()}
all_dept_map = {**reverse_synonyms, **direct_synonyms, "отдел внутреннего контроля": "ОВК", "отдел вневедомственного контроля": "ОВК"}
if "Подразделение" in df_res.columns:
df_res["Подразделение"] = df_res["Подразделение"].apply(
lambda d: all_dept_map.get(str(d).strip().lower(), str(d).strip())
)
absences_map = {}
if df_absences_1c is not None and not df_absences_1c.empty:
fio_col = next((c for c in ["fio_clean", "ФИО", "Сотрудник"] if c in df_absences_1c.columns), None)
reason_col = next((c for c in ["Вид_отсутствия", "Причина", "причина отсутствия"] if c in df_absences_1c.columns), None)
if fio_col and reason_col:
for _, row in df_absences_1c.iterrows():
fio = normalize_fio(str(row[fio_col]))
reason = str(row[reason_col]).strip()
if reason and reason.lower() != "nan":
absences_map[fio] = reason
manual_reasons_map = {}
try:
from services.manual_absences_repo import get_active_manual_absences_for_date
# date_clean берется из даты контекста либо из текущих суток
target_date_val = df_res.get('Дата', pd.Series()).iloc[0] if 'Дата' in df_res.columns and not df_res.empty else None
if target_date_val:
m_records = get_active_manual_absences_for_date(str(target_date_val))
for mr in m_records:
manual_reasons_map[mr['fio_clean']] = mr['reason']
except Exception:
pass
df_res["detailed_reason"] = df_res["fio_clean"].map(manual_reasons_map).fillna("")
df_res["причина отсутствия"] = df_res["fio_clean"].map(absences_map)
df_res["Вид_отсутствия"] = df_res["причина отсутствия"]
exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f]
exc_depts = [d.strip().upper() for d in exceptions_cfg.get("departments", []) if d]
exc_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p]
pos_kw = [k.strip().lower() for k in exceptions_cfg.get("position_keywords", []) if k]
whitelist_fios = [normalize_fio(f) for f in exceptions_cfg.get("include_fio", []) if f]
df_res["is_excluded"] = False
for idx, row in df_res.iterrows():
fio = row.get("fio_clean", "")
has_official_absence = pd.notna(row.get("Вид_отсутствия")) and str(row.get("Вид_отсутствия")).strip() not in ["", "nan", "None", "Исключение"]
if fio in whitelist_fios:
df_res.at[idx, "is_excluded"] = False
continue
dep = str(row.get("Подразделение", "")).upper()
pos = str(row.get("Должность", "")).lower()
is_match_exc = (fio in exc_fios or dep in exc_depts or any(d in dep for d in exc_depts) or pos in exc_pos or any(k in pos for k in pos_kw))
if is_match_exc:
if has_official_absence:
df_res.at[idx, "is_excluded"] = False
else:
df_res.at[idx, "is_excluded"] = True
mask_exc = (df_res["is_excluded"] == True) & (df_res["Вид_отсутствия"].isna() | (df_res["Вид_отсутствия"] == ""))
df_res.loc[mask_exc, "Вид_отсутствия"] = "Исключение"
df_res.loc[mask_exc, "причина отсутствия"] = "Исключение"
return df_res
def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]:
total_staff = len(df_merged)
came_to_office_mask = (df_merged["Начало_дня"].astype(str).str.strip().ne("Нет входа")) & (df_merged.get("is_excluded", False) == False)
exc_without_doc_mask = (df_merged.get("is_excluded", False) == True) & (
df_merged["Вид_отсутствия"].isna() |
df_merged["Вид_отсутствия"].astype(str).str.strip().isin(["", "nan", "Исключение"])
)
working_in_office_count = len(df_merged[came_to_office_mask | exc_without_doc_mask])
df_not_working = df_merged[~came_to_office_mask & ~exc_without_doc_mask]
reason_series = df_not_working["причина отсутствия"].astype(str).str.lower()
is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False)
remote_home = df_not_working[is_remote_mask]
remote_home_count = len(remote_home)
df_remaining_absent = df_not_working[~is_remote_mask]
has_doc_mask = df_remaining_absent["причина отсутствия"].notna() & \
df_remaining_absent["причина отсутствия"].ne("") & \
df_remaining_absent["причина отсутствия"].ne("nan") & \
(~df_remaining_absent["причина отсутствия"].astype(str).str.startswith("Исключение"))
official_absent_count = len(df_remaining_absent[has_doc_mask])
unknown = df_remaining_absent[~has_doc_mask]
unknown_count = len(unknown)
return {
"total_staff": total_staff,
"working_in_office_count": working_in_office_count,
"remote_home_count": remote_home_count,
"official_absent_count": official_absent_count,
"unknown_count": unknown_count,
"unknown_list": unknown[["fio_clean", "Подразделение", "Должность"]].to_dict(orient="records") if not unknown.empty else []
}
+59
View File
@@ -0,0 +1,59 @@
"""
===============================================================================
FILE: services/scud_etl/otchet_generator.py
ROLE: Генератор Детального Отчета за прошлые смены (строго по итоговому Y-снапшоту).
===============================================================================
"""
import os
import logging
from typing import Dict, Any, Optional
import pandas as pd
from config import DATE_YESTERDAY
from core.database import load_scud_from_db_by_snapshot
from services.scud_etl.pipeline import load_1c_files_for_date
from services.scud_etl.merger import merge_scud_and_1c
from services.excel_exporter import generate_detailed_excel, get_dated_reports_dir, format_date_ru
logger = logging.getLogger("OTCHET_GENERATOR")
def generate_otchet_service(
target_date: Optional[str] = None,
snapshot_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Формирует детальный отчет за прошедшую смену:
- target_date: дата отчета (по умолчанию вчерашний рабочий день).
- snapshot_id: опциональный ID (по умолчанию выбирается итоговый вечерний срез Y).
"""
date_clean = str(target_date or DATE_YESTERDAY).replace('_', '.')
df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=snapshot_id)
if df_scud is None or df_scud.empty:
return {
"status": "error",
"message": f"Итоговый срез СКУД (Y) за {date_clean} не найден в базе данных."
}
df_staff, df_abs = load_1c_files_for_date(date_clean)
df_merged = merge_scud_and_1c(df_scud, df_staff, df_abs)
filename = f"{format_date_ru(date_clean)} отчет.xlsx"
generate_detailed_excel(df_merged, date_str=date_clean, filename=filename)
target_dir = get_dated_reports_dir(date_clean)
full_filepath = os.path.join(target_dir, filename)
return {
"status": "success",
"report_type": "OTCHET",
"date": date_clean,
"snapshot_id": snapshot_id or "AUTO_Y_FINAL",
"filename": filename,
"filepath": full_filepath,
"download_url": f"/api/v1/files/download/reports/{os.path.basename(full_filepath)}",
"total_rows": len(df_merged),
"message": f"Детальный отчет за {date_clean} успешно сформирован."
}
+64
View File
@@ -0,0 +1,64 @@
"""
===============================================================================
FILE: services/scud_etl/pipeline.py
===============================================================================
"""
import os
import logging
from typing import Optional, Dict, Any, Tuple
import pandas as pd
from core.connection import get_connection
from core.database import load_scud_from_db_by_snapshot
from config import DATA_DIR
from services.data_loader import load_staff_data, load_absent_data
logger = logging.getLogger("SCUD_PIPELINE")
def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = False) -> Optional[pd.DataFrame]:
"""
Загружает наилучший срез СКУД за дату.
Если prefer_final_y=True — отдает предпочтение финишному Y (23:59:59).
"""
with get_connection() as conn:
cursor = conn.cursor()
target_snap_id = None
if prefer_final_y:
cursor.execute("""
SELECT snapshot_id
FROM scud_logs
WHERE log_date = ?
AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%23:59:59' OR snapshot_time LIKE '%22:00:00')
ORDER BY id DESC LIMIT 1
""", (date_str,))
row = cursor.fetchone()
if row:
target_snap_id = row[0]
if not target_snap_id:
cursor.execute("""
SELECT snapshot_id
FROM scud_logs
WHERE log_date = ?
ORDER BY id DESC LIMIT 1
""", (date_str,))
row = cursor.fetchone()
if row:
target_snap_id = row[0]
if not target_snap_id:
return None
return load_scud_from_db_by_snapshot(date_str, snapshot_param=target_snap_id)
def load_1c_files_for_date(date_str: str) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame]]:
"""
Загружает реестры штата и отсутствий 1С на указанную дату через data_loader.
"""
df_staff = load_staff_data(date_str)
df_abs = load_absent_data(date_str)
return df_staff, df_abs
+116
View File
@@ -0,0 +1,116 @@
"""
===============================================================================
FILE: services/scud_etl/sql_queries.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / scud_etl
ROLE: Хранилище сырых SQL-шаблонов для выгрузки из MS SQL Server (СКУД Орион Pro).
AI-CONTEXT-ANCHORS:
- ANCHOR[SQL_SCUD_EXPORT_TEMPLATE]: T-SQL запрос с расчетом первой активности и длительности.
===============================================================================
"""
# ANCHOR[SQL_SCUD_EXPORT_TEMPLATE]
SCUD_EXPORT_QUERY_TEMPLATE = r"""
DECLARE @InputDate DATE = '{target_date}';
DECLARE @TargetDate DATE = @InputDate;
DECLARE @StartDate DATETIME = CAST(@TargetDate AS DATETIME);
DECLARE @EndDate DATETIME = DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate));
WITH DailyLogs AS (
SELECT
log.HozOrgan AS EmployeeID,
log.TimeVal,
log.Event,
log.Mode,
CASE
WHEN log.Mode = 2 OR log.Event IN (29, 27, 33) THEN 'OUT'
WHEN log.Mode = 1 OR log.Event IN (28, 26, 32) THEN 'IN'
ELSE 'OTHER'
END AS Direction,
ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC) AS RowNumDesc
FROM pLogData log WITH (NOLOCK)
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
AND log.HozOrgan IS NOT NULL
AND log.HozOrgan > 0
AND log.Event IN (26, 27, 28, 29, 32, 33, 54, 55, 64, 65)
),
Passages AS (
SELECT
EmployeeID,
MIN(TimeVal) AS FirstRawEvent,
MAX(TimeVal) AS LastRawEvent,
MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn,
MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS LastOut,
MAX(CASE WHEN RowNumDesc = 1 THEN Direction END) AS LastEventType
FROM DailyLogs
GROUP BY EmployeeID
)
SELECT
N'ЛЕНМОРНИИПРОЕКТ' AS [Фирма],
ISNULL(CAST(div.Name AS NVARCHAR(255)), N'Без подразделения') AS [Подразделение],
LTRIM(RTRIM(
ISNULL(CAST(p.Name AS NVARCHAR(255)), N'') +
CASE WHEN p.FirstName IS NOT NULL AND CAST(p.FirstName AS NVARCHAR(255)) <> ''
THEN N' ' + CAST(p.FirstName AS NVARCHAR(255)) ELSE N'' END +
CASE WHEN p.MidName IS NOT NULL AND CAST(p.MidName AS NVARCHAR(255)) <> ''
THEN N' ' + CAST(p.MidName AS NVARCHAR(255)) ELSE N'' END
)) AS [Сотрудник],
ISNULL(CAST(post.Name AS NVARCHAR(255)), N'—') AS [Должность],
ISNULL(CAST(p.TabNumber AS NVARCHAR(50)), N'—') AS [Таб_№],
CONVERT(VARCHAR(10), @TargetDate, 104) AS [Дата],
ISNULL(CAST(CONVERT(VARCHAR(8), pass.FirstIn, 108) AS NVARCHAR(20)), N'Нет входа') AS [Начало_дня],
CASE
WHEN pass.FirstIn IS NULL AND pass.FirstRawEvent IS NOT NULL
THEN CAST(CONVERT(VARCHAR(8), pass.FirstRawEvent, 108) AS NVARCHAR(20))
ELSE N'—'
END AS [Первая_активность],
CASE
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn)
THEN N'Нет выхода'
WHEN pass.LastOut IS NOT NULL AND pass.LastOut > pass.FirstIn
THEN CAST(CONVERT(VARCHAR(8), pass.LastOut, 108) AS NVARCHAR(20))
WHEN @TargetDate < CAST(GETDATE() AS DATE) AND pass.LastRawEvent IS NOT NULL AND pass.LastRawEvent > ISNULL(pass.FirstIn, pass.FirstRawEvent)
THEN CAST(CONVERT(VARCHAR(8), pass.LastRawEvent, 108) AS NVARCHAR(20))
ELSE N'Нет выхода'
END AS [Конец_дня],
CASE
WHEN pass.EmployeeID IS NOT NULL AND (pass.FirstIn IS NOT NULL OR pass.FirstRawEvent IS NOT NULL) THEN
RIGHT('0' + CAST(DATEDIFF(MINUTE,
ISNULL(pass.FirstIn, pass.FirstRawEvent),
CASE
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE()
ELSE ISNULL(pass.LastOut, pass.LastRawEvent)
END) / 60 AS VARCHAR), 2) + ':' +
RIGHT('0' + CAST(DATEDIFF(MINUTE,
ISNULL(pass.FirstIn, pass.FirstRawEvent),
CASE
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE()
ELSE ISNULL(pass.LastOut, pass.LastRawEvent)
END) % 60 AS VARCHAR), 2)
ELSE N'00:00'
END AS [Находился_в_здании],
CASE
WHEN pass.EmployeeID IS NOT NULL THEN N'Присутствовал'
ELSE N'Отсутствовал (Нет событий)'
END AS [Статус]
FROM pList p WITH (NOLOCK)
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID
LEFT JOIN Passages pass ON p.ID = pass.EmployeeID
WHERE
ISNULL(p.StatusRecord, 0) = 0
AND p.DateTimeInArchive IS NULL
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Аренд%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'Без подразделения', N'')
AND p.Name NOT LIKE N'бр.%'
AND p.Name NOT LIKE N'Гость%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'БГИ', N'КНР')
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Рабоч%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Врем%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практика%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'тест%'
AND ISNULL(CAST(post.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практикант%'
ORDER BY p.Name ASC;
"""
+80
View File
@@ -0,0 +1,80 @@
"""
===============================================================================
FILE: services/scud_etl/svodka_generator.py
ROLE: Генератор Ежедневной Сводки (оперативный контроль, текущий срез).
===============================================================================
"""
import os
import logging
from typing import Dict, Any, Optional
import pandas as pd
from config import DATE_TODAY
from core.database import load_scud_from_db_by_snapshot
from services.scud_etl.pipeline import load_1c_files_for_date
from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics
from services.scud_etl.anomaly_detector import detect_registry_anomalies
from services.snapshots.finder import find_or_create_snapshot_for_time
from services.excel_exporter import generate_summary_excel, get_dated_reports_dir, format_date_ru
logger = logging.getLogger("SVODKA_GENERATOR")
def generate_svodka_service(
target_date: Optional[str] = None,
target_time: Optional[str] = None,
snapshot_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Формирует оперативную сводку на указанную дату / время:
- target_date: дата сводки (по умолчанию сегодня).
- target_time: время среза (например '14:30').
- snapshot_id: точный ID среза.
"""
date_clean = str(target_date or DATE_TODAY).replace('_', '.')
applied_note = ""
# Если передано время, но не указан конкретный snapshot_id — ищем ближайший или запрашиваем экспорт
if target_time and not snapshot_id:
found_id, note = find_or_create_snapshot_for_time(date_clean, target_time, allow_ondemand_export=True)
snapshot_id = found_id
applied_note = note
if note:
logger.info(note)
df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=snapshot_id)
if df_scud is None or df_scud.empty:
return {
"status": "error",
"message": f"Срез СКУД за {date_clean} ({applied_note or snapshot_id or 'последний доступный'}) не найден в базе."
}
df_staff, df_abs = load_1c_files_for_date(date_clean)
df_merged = merge_scud_and_1c(df_scud, df_staff, df_abs)
metrics = calculate_summary_metrics(df_merged)
anomalies = detect_registry_anomalies(df_merged, df_raw_scud=df_scud)
# Добавляем суффикс времени в имя файла, если сводка строилась на точный срез
time_suffix = f" на {target_time.replace(':', '-')}" if target_time else ""
filename = f"{format_date_ru(date_clean)} сводка{time_suffix}.xlsx"
generate_summary_excel(df_merged, date_str=date_clean, filename=filename)
target_dir = get_dated_reports_dir(date_clean)
full_filepath = os.path.join(target_dir, filename)
return {
"status": "success",
"report_type": "SVODKA",
"date": date_clean,
"target_time": target_time,
"snapshot_id": snapshot_id or "AUTO_LATEST",
"filename": filename,
"filepath": full_filepath,
"download_url": f"/api/v1/files/download/reports/{os.path.basename(full_filepath)}",
"metrics": metrics,
"anomalies_count": len(anomalies),
"note": applied_note,
"message": f"Ежедневная сводка на {date_clean} {target_time or ''} успешно сформирована."
}
+229 -84
View File
@@ -1,6 +1,10 @@
""" """
Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в SQLite и Excel. ===============================================================================
Добавлена фиксация Первой Активности (без учета направления) и среза по времени. FILE: services/scud_export.py
ROLE: Прямой экспорт данных СКУД Орион (MS SQL) в SQLite и чистый Excel (XlsxWriter).
Корректная фильтрация транзитных проходов турникетов парковки и двора.
Учет только левого PERCo (DoorIndex = 1) и факта физического прохода (Event = 32).
===============================================================================
""" """
import argparse import argparse
@@ -10,12 +14,18 @@ import sys
import warnings import warnings
from datetime import datetime, timedelta from datetime import datetime, timedelta
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
if ROOT_DIR not in sys.path:
sys.path.insert(0, ROOT_DIR)
import pandas as pd import pandas as pd
import pyodbc import pyodbc
from openpyxl.utils import get_column_letter import xlsxwriter
from config import SCUD_DIR, clean_scud_fio_light, load_exceptions from config import SCUD_DIR, clean_scud_fio_light, load_exceptions
from core.database import save_scud_to_db, has_scud_logs_for_date, has_yesterday_final_snapshot from core.database import save_scud_to_db, has_scud_logs_for_date, has_yesterday_final_snapshot
from core.repositories.scud_repo import save_raw_events_to_db
warnings.filterwarnings("ignore", message="pandas only supports SQLAlchemy connectable") warnings.filterwarnings("ignore", message="pandas only supports SQLAlchemy connectable")
@@ -28,14 +38,20 @@ LOG_FILE = os.path.join(LOG_DIR, f"export_{TODAY_DATE_STR}.log")
logger = logging.getLogger("scud_export") logger = logging.getLogger("scud_export")
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
logger.propagate = False
if not logger.handlers: if not logger.handlers:
_file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8") try:
_file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
_file_handler.setFormatter(_formatter)
logger.addHandler(_file_handler)
except (PermissionError, OSError) as e:
sys.stderr.write(f"Предупреждение: невозможно создать лог-файл {LOG_FILE}: {e}\n")
_console_handler = logging.StreamHandler(sys.stdout) _console_handler = logging.StreamHandler(sys.stdout)
_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S") _formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
_file_handler.setFormatter(_formatter)
_console_handler.setFormatter(_formatter) _console_handler.setFormatter(_formatter)
logger.addHandler(_file_handler)
logger.addHandler(_console_handler) logger.addHandler(_console_handler)
@@ -52,7 +68,7 @@ def log(message: str, level: str = "INFO"):
logger.log(level_map.get(level, logging.INFO), message) logger.log(level_map.get(level, logging.INFO), message)
SERVER_NAME = r"172.16.31.221\SQL" SERVER_NAME = r"172.16.200.147\SQL"
DATABASE_NAME = "Orion-14.01.21-1" DATABASE_NAME = "Orion-14.01.21-1"
SQL_USER = "sa" SQL_USER = "sa"
SQL_PASSWORD = "123456" SQL_PASSWORD = "123456"
@@ -63,25 +79,36 @@ DECLARE @InputDate DATE = '{target_date}';
DECLARE @TargetDate DATE = @InputDate; DECLARE @TargetDate DATE = @InputDate;
DECLARE @StartDate DATETIME = CAST(@TargetDate AS DATETIME); DECLARE @StartDate DATETIME = CAST(@TargetDate AS DATETIME);
DECLARE @EndDate DATETIME = DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate)); DECLARE @EndDate DATETIME = {end_datetime_sql};
WITH DailyLogs AS ( WITH PercoPassages AS (
-- Физические факты прохода (Event = 32)
SELECT SELECT
log.HozOrgan AS EmployeeID, log.HozOrgan AS EmployeeID,
log.TimeVal, log.TimeVal,
log.Event,
log.Mode,
CASE CASE
WHEN log.Mode = 2 OR log.Event IN (29, 27, 33) THEN 'OUT' WHEN log.Mode = 1 THEN 'IN'
WHEN log.Mode = 1 OR log.Event IN (28, 26, 32) THEN 'IN' WHEN log.Mode = 2 THEN 'OUT'
ELSE 'OTHER' ELSE 'OTHER'
END AS Direction, END AS Direction
ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC) AS RowNumDesc
FROM pLogData log WITH (NOLOCK) FROM pLogData log WITH (NOLOCK)
INNER JOIN pList p WITH (NOLOCK) ON log.HozOrgan = p.ID
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
AND log.HozOrgan IS NOT NULL AND log.HozOrgan IS NOT NULL
AND log.HozOrgan > 0 AND log.HozOrgan > 0
AND log.Event IN (26, 27, 28, 29, 32, 33, 54, 55, 64, 65) AND log.Event = 32
AND log.Mode IN (1, 2)
AND (
-- Контур 1: Левый турникет открыт для всех
log.DoorIndex = 1
OR
-- Контур 2: Правый турникет разрешен только для реестра двора
(
log.DoorIndex = 2
AND ({turnstile_filter_sql})
)
)
), ),
Passages AS ( Passages AS (
SELECT SELECT
@@ -89,10 +116,21 @@ Passages AS (
MIN(TimeVal) AS FirstRawEvent, MIN(TimeVal) AS FirstRawEvent,
MAX(TimeVal) AS LastRawEvent, MAX(TimeVal) AS LastRawEvent,
MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn, MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn,
MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS LastOut, MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS FinalOut
MAX(CASE WHEN RowNumDesc = 1 THEN Direction END) AS LastEventType FROM PercoPassages
FROM DailyLogs
GROUP BY EmployeeID GROUP BY EmployeeID
),
EvaluatedPassages AS (
SELECT
p.*,
CASE
WHEN p.FinalOut IS NOT NULL
AND p.FirstIn IS NOT NULL
AND p.FinalOut > DATEADD(MINUTE, 5, p.FirstIn)
THEN p.FinalOut
ELSE NULL
END AS FilteredLastOut
FROM Passages p
) )
SELECT SELECT
N'ЛЕНМОРНИИПРОЕКТ' AS [Фирма], N'ЛЕНМОРНИИПРОЕКТ' AS [Фирма],
@@ -114,12 +152,8 @@ SELECT
ELSE N'—' ELSE N'—'
END AS [Первая_активность], END AS [Первая_активность],
CASE CASE
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) WHEN pass.FilteredLastOut IS NOT NULL
THEN N'Нет выхода' THEN CAST(CONVERT(VARCHAR(8), pass.FilteredLastOut, 108) AS NVARCHAR(20))
WHEN pass.LastOut IS NOT NULL AND pass.LastOut > pass.FirstIn
THEN CAST(CONVERT(VARCHAR(8), pass.LastOut, 108) AS NVARCHAR(20))
WHEN @TargetDate < CAST(GETDATE() AS DATE) AND pass.LastRawEvent IS NOT NULL AND pass.LastRawEvent > ISNULL(pass.FirstIn, pass.FirstRawEvent)
THEN CAST(CONVERT(VARCHAR(8), pass.LastRawEvent, 108) AS NVARCHAR(20))
ELSE N'Нет выхода' ELSE N'Нет выхода'
END AS [Конец_дня], END AS [Конец_дня],
CASE CASE
@@ -127,14 +161,14 @@ SELECT
RIGHT('0' + CAST(DATEDIFF(MINUTE, RIGHT('0' + CAST(DATEDIFF(MINUTE,
ISNULL(pass.FirstIn, pass.FirstRawEvent), ISNULL(pass.FirstIn, pass.FirstRawEvent),
CASE CASE
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE() WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut
ELSE ISNULL(pass.LastOut, pass.LastRawEvent) ELSE @EndDate
END) / 60 AS VARCHAR), 2) + ':' + END) / 60 AS VARCHAR), 2) + ':' +
RIGHT('0' + CAST(DATEDIFF(MINUTE, RIGHT('0' + CAST(DATEDIFF(MINUTE,
ISNULL(pass.FirstIn, pass.FirstRawEvent), ISNULL(pass.FirstIn, pass.FirstRawEvent),
CASE CASE
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE() WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut
ELSE ISNULL(pass.LastOut, pass.LastRawEvent) ELSE @EndDate
END) % 60 AS VARCHAR), 2) END) % 60 AS VARCHAR), 2)
ELSE N'00:00' ELSE N'00:00'
END AS [Находился_в_здании], END AS [Находился_в_здании],
@@ -145,7 +179,7 @@ SELECT
FROM pList p WITH (NOLOCK) FROM pList p WITH (NOLOCK)
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID
LEFT JOIN Passages pass ON p.ID = pass.EmployeeID LEFT JOIN EvaluatedPassages pass ON p.ID = pass.EmployeeID
WHERE WHERE
ISNULL(p.StatusRecord, 0) = 0 ISNULL(p.StatusRecord, 0) = 0
AND p.DateTimeInArchive IS NULL AND p.DateTimeInArchive IS NULL
@@ -162,33 +196,100 @@ WHERE
ORDER BY p.Name ASC; ORDER BY p.Name ASC;
""" """
SQL_RAW_EVENTS_QUERY = r"""
DECLARE @InputDate DATE = '{target_date}';
DECLARE @StartDate DATETIME = CAST(@InputDate AS DATETIME);
DECLARE @EndDate DATETIME = {end_datetime_sql};
def auto_fit_columns(file_path: str, sheet_name: str, padding: float = 2.0, min_width: float = 8.0, max_width: float = 60.0): SELECT
from openpyxl import load_workbook log.TimeVal,
log.HozOrgan,
wb = load_workbook(file_path) LTRIM(RTRIM(
ws = wb[sheet_name] ISNULL(CAST(p.Name AS NVARCHAR(255)), N'') +
CASE WHEN p.FirstName IS NOT NULL AND CAST(p.FirstName AS NVARCHAR(255)) <> ''
for col_cells in ws.columns: THEN N' ' + CAST(p.FirstName AS NVARCHAR(255)) ELSE N'' END +
max_len = 0 CASE WHEN p.MidName IS NOT NULL AND CAST(p.MidName AS NVARCHAR(255)) <> ''
col_letter = get_column_letter(col_cells[0].column) THEN N' ' + CAST(p.MidName AS NVARCHAR(255)) ELSE N'' END
for cell in col_cells: )) AS [Сотрудник],
if cell.value is not None: ISNULL(CAST(div.Name AS NVARCHAR(255)), N'Без подразделения') AS [Подразделение],
cell_len = len(str(cell.value)) log.Event,
if cell_len > max_len: log.Mode,
max_len = cell_len log.DoorIndex,
width = max(min_width, min(max_len + padding, max_width)) CASE
ws.column_dimensions[col_letter].width = width WHEN log.Mode = 2 THEN 'OUT'
WHEN log.Mode = 1 THEN 'IN'
wb.save(file_path) WHEN log.Event IN (2, 27, 29, 33, 55, 65) THEN 'OUT'
WHEN log.Event IN (1, 21, 26, 54, 64) THEN 'IN'
ELSE 'OTHER'
END AS Direction
FROM pLogData log WITH (NOLOCK)
INNER JOIN pList p WITH (NOLOCK) ON log.HozOrgan = p.ID
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
AND log.HozOrgan IS NOT NULL
AND log.HozOrgan > 0
AND log.Event IN (28, 32)
AND ISNULL(p.StatusRecord, 0) = 0
ORDER BY log.TimeVal ASC;
"""
def get_targets(input_date: str | None): def save_df_to_clean_excel(df: pd.DataFrame, file_path: str, sheet_name: str = "Отчет"):
workbook = xlsxwriter.Workbook(file_path, {'constant_memory': False})
worksheet = workbook.add_worksheet(sheet_name)
fmt_header = workbook.add_format({
'bold': True,
'bg_color': '#D9E1F2',
'border': 1,
'border_color': '#D3D3D3',
'align': 'center',
'valign': 'vcenter',
'font_name': 'Calibri',
'font_size': 11
})
fmt_cell = workbook.add_format({
'border': 1,
'border_color': '#D3D3D3',
'valign': 'vcenter',
'align': 'left',
'font_name': 'Calibri',
'font_size': 11
})
headers = list(df.columns)
col_widths = [len(str(h)) for h in headers]
for col_idx, header in enumerate(headers):
worksheet.write(0, col_idx, str(header), fmt_header)
for row_idx, row_values in enumerate(df.values, start=1):
for col_idx, val in enumerate(row_values):
if pd.isna(val) or val is None:
val_str = ""
elif isinstance(val, bool):
val_str = "Да" if val else "Нет"
else:
val_str = str(val)
worksheet.write(row_idx, col_idx, val_str, fmt_cell)
if len(val_str) > col_widths[col_idx]:
col_widths[col_idx] = len(val_str)
for col_idx, width in enumerate(col_widths):
worksheet.set_column(col_idx, col_idx, min(max(width + 3, 10), 45))
workbook.close()
def get_targets(input_date: str | None, input_time: str | None = None):
targets = [] targets = []
if input_date: if input_date:
try: try:
parsed = datetime.strptime(input_date, "%d.%m.%Y").date() parsed = datetime.strptime(input_date.replace('_', '.'), "%d.%m.%Y").date()
targets.append({"name": "Указанная дата", "date": parsed}) targets.append({"name": "Указанная дата", "date": parsed, "target_time": input_time})
except ValueError: except ValueError:
log(f"ОШИБКА: Неверный формат даты '{input_date}'. Используйте ДД.ММ.ГГГГ", "ERROR") log(f"ОШИБКА: Неверный формат даты '{input_date}'. Используйте ДД.ММ.ГГГГ", "ERROR")
sys.exit(1) sys.exit(1)
@@ -197,13 +298,13 @@ def get_targets(input_date: str | None):
yesterday = (now - timedelta(days=3 if now.weekday() == 0 else 1)).date() yesterday = (now - timedelta(days=3 if now.weekday() == 0 else 1)).date()
today = now.date() today = now.date()
targets.append({"name": "Вчера", "date": yesterday}) targets.append({"name": "Вчера", "date": yesterday, "target_time": None})
targets.append({"name": "Сегодня", "date": today}) targets.append({"name": "Сегодня", "date": today, "target_time": None})
return targets return targets
def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: bool = False): def run_export(input_date: str | None = None, input_time: str | None = None, save_xlsx: bool = True, debug: bool = False):
if debug: if debug:
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
log("=== ВКЛЮЧЕН РЕЖИМ ОТЛАДКИ (DEBUG MODE) ===", "WARNING") log("=== ВКЛЮЧЕН РЕЖИМ ОТЛАДКИ (DEBUG MODE) ===", "WARNING")
@@ -211,7 +312,7 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
log("=== [ЭТАП 0] Выгрузка свежих данных СКУД напрямую из БД Орион ===") log("=== [ЭТАП 0] Выгрузка свежих данных СКУД напрямую из БД Орион ===")
os.makedirs(SCUD_DIR, exist_ok=True) os.makedirs(SCUD_DIR, exist_ok=True)
targets = get_targets(input_date) targets = get_targets(input_date, input_time)
conn_str = ( conn_str = (
f"DRIVER={{{ODBC_DRIVER}}};" f"DRIVER={{{ODBC_DRIVER}}};"
f"SERVER={SERVER_NAME};" f"SERVER={SERVER_NAME};"
@@ -227,21 +328,66 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
processing_date = target["date"] processing_date = target["date"]
processing_date_str = processing_date.strftime("%d.%m.%Y") processing_date_str = processing_date.strftime("%d.%m.%Y")
period_label = target["name"] period_label = target["name"]
is_yesterday = (period_label == "Вчера") target_time = target["target_time"]
today_date = datetime.now().date()
is_past_day = (processing_date < today_date) or (period_label == "Вчера")
# ⚡️ ПРОВЕРКА НАЛИЧИЯ ВЧЕРАШНЕГО ДНЯ В SQLITE: if is_past_day and not target_time and has_yesterday_final_snapshot(processing_date_str):
# Если это вчерашний день и его снапшот уже зафиксирован в SQLite — пропускаем тяжелый запрос к MS SQL log(f"[ℹ️] День ({processing_date_str}) уже зафиксирован финишным снапшотом _FINAL. Пропускаем.")
if is_yesterday and has_yesterday_final_snapshot(processing_date_str):
log(f"[ℹ️] Вчерашний день ({processing_date_str}) уже зафиксирован финишным снапшотом Y. Пропускаем запрос к MS SQL.")
continue continue
if is_yesterday: # 1. Формируем фильтр правого турникета из exceptions_registry
snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} 22:00:00" exc_data = load_exceptions()
t_fios = [f.replace("'", "''") for f in exc_data.get('turnstile_fio', []) if f]
t_depts = [d.replace("'", "''") for d in exc_data.get('turnstile_departments', []) if d]
conditions = []
if t_fios:
fio_in = ", ".join([f"N'{f}'" for f in t_fios])
# Склеиваем Фамилию + Имя + Отчество для точного сравнения с реестром ФИО
full_fio_sql = (
"LTRIM(RTRIM("
"ISNULL(CAST(p.Name AS NVARCHAR(255)), N'') + "
"CASE WHEN p.FirstName IS NOT NULL AND CAST(p.FirstName AS NVARCHAR(255)) <> '' THEN N' ' + CAST(p.FirstName AS NVARCHAR(255)) ELSE N'' END + "
"CASE WHEN p.MidName IS NOT NULL AND CAST(p.MidName AS NVARCHAR(255)) <> '' THEN N' ' + CAST(p.MidName AS NVARCHAR(255)) ELSE N'' END"
"))"
)
conditions.append(f"{full_fio_sql} IN ({fio_in})")
if t_depts:
dept_in = ", ".join([f"N'{d}'" for d in t_depts])
conditions.append(f"ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') IN ({dept_in})")
turnstile_filter_sql = " OR ".join(conditions) if conditions else "1 = 0"
# 2. Безопасное математическое определение @EndDate через DATEADD (независимо от локали сервера)
if target_time:
t_clean = target_time.strip()
t_parts = t_clean.split(":")
h = int(t_parts[0])
m = int(t_parts[1]) if len(t_parts) > 1 else 0
s = int(t_parts[2]) if len(t_parts) > 2 else 0
snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} {h:02d}:{m:02d}:{s:02d}"
end_datetime_sql = f"DATEADD(SECOND, {s}, DATEADD(MINUTE, {m}, DATEADD(HOUR, {h}, @StartDate)))"
is_final = False
elif is_past_day:
snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} 23:59:59"
end_datetime_sql = "DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate))"
is_final = True
else: else:
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
end_datetime_sql = "GETDATE()"
is_final = False
log(f"--- Обработка периода: {period_label} ({processing_date_str}) --- [Снапшот: {snapshot_time}]") log(f"--- Обработка периода: {period_label} ({processing_date_str}) --- [Срез: {snapshot_time}]")
sql_query = SQL_QUERY_TEMPLATE.format(target_date=processing_date.strftime("%Y-%m-%d"))
sql_query = SQL_QUERY_TEMPLATE.format(
target_date=processing_date.strftime("%Y-%m-%d"),
end_datetime_sql=end_datetime_sql,
turnstile_filter_sql=turnstile_filter_sql
)
connection = None connection = None
try: try:
@@ -259,7 +405,18 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
df['Пришел'] = df['Статус'].str.contains('Присутствовал', case=False, na=False) & (~mask_anomaly) df['Пришел'] = df['Статус'].str.contains('Присутствовал', case=False, na=False) & (~mask_anomaly)
save_scud_to_db(df, processing_date_str, snapshot_time=snapshot_time, is_yesterday=is_yesterday) save_scud_to_db(df, processing_date_str, snapshot_time=snapshot_time, is_yesterday=is_final)
raw_sql = SQL_RAW_EVENTS_QUERY.format(
target_date=processing_date.strftime("%Y-%m-%d"),
end_datetime_sql=end_datetime_sql
)
df_raw = pd.read_sql(raw_sql, connection)
if len(df_raw) > 0:
df_raw['fio_clean'] = df_raw['Сотрудник'].apply(clean_scud_fio_light)
inserted_count = save_raw_events_to_db(df_raw, processing_date_str)
log(f"[✓] В scud_events_raw сохранено {inserted_count} сырых событий за {processing_date_str}!", "SUCCESS")
log(f"[✓] Записи за {processing_date_str} успешно сохранены в SQLite!", "SUCCESS") log(f"[✓] Записи за {processing_date_str} успешно сохранены в SQLite!", "SUCCESS")
if save_xlsx: if save_xlsx:
@@ -272,8 +429,7 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
except OSError as e: except OSError as e:
log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR") log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR")
df.to_excel(file_path, sheet_name="Отчет", index=False, engine="openpyxl") save_df_to_clean_excel(df, file_path, sheet_name="Отчет")
auto_fit_columns(file_path, sheet_name="Отчет")
log(f"[✓] Успешно экспортирован файл: data/scud/{file_name}", "SUCCESS") log(f"[✓] Успешно экспортирован файл: data/scud/{file_name}", "SUCCESS")
else: else:
log(f"Запрос за {processing_date_str} вернул 0 строк.", "WARNING") log(f"Запрос за {processing_date_str} вернул 0 строк.", "WARNING")
@@ -290,22 +446,11 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
if __name__ == "__main__": if __name__ == "__main__":
help_text = """ parser = argparse.ArgumentParser()
Модуль прямого экспорта данных СКУД Орион Pro (MS SQL Server) в SQLite и Excel. parser.add_argument("--date", dest="input_date", default=None, help="Дата среза (ДД.ММ.ГГГГ)")
parser.add_argument("--time", dest="input_time", default=None, help="Время среза (ЧЧ:ММ)")
ПРИМЕРЫ ЗАПУСКА: parser.add_argument("-d", "--debug", action="store_true")
python services/scud_export.py -- Автоматическая выгрузка за Сегодня и Вчера parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True)
python services/scud_export.py --date 05.08.2026 -- Точечная выгрузка СКУД за конкретную дату
python services/scud_export.py --no-xlsx -- Сохранение логов СКУД ТОЛЬКО в SQLite без дублирования в XLSX
python services/scud_export.py -d -- Запуск в режиме отладки MS SQL запросов (DEBUG)
"""
parser = argparse.ArgumentParser(
description=help_text,
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--date", dest="input_date", default=None, help="Дата выгрузки в формате ДД.ММ.ГГГГ (по умолчанию выгружаются Вчера и Сегодня)")
parser.add_argument("-d", "--debug", action="store_true", help="Включить подробные логи отладки подключения и запросов к MS SQL")
parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True, help="Отключить генерирование промежуточных XLSX-файлов в папке data/scud/")
args = parser.parse_args() args = parser.parse_args()
run_export(args.input_date, save_xlsx=args.save_xlsx, debug=args.debug) run_export(args.input_date, input_time=args.input_time, save_xlsx=args.save_xlsx, debug=args.debug)
+10 -1
View File
@@ -1,3 +1,10 @@
"""
===============================================================================
FILE: services/share_copier.py
ROLE: Синхронизация файлов 1С (Штат и Отсутствия) с сетевой шары в data/1c/.
===============================================================================
"""
import os import os
import shutil import shutil
from config import DATE_TODAY, DATE_YESTERDAY, ZUP_1C_DIR, SHARE_1C_DIR from config import DATE_TODAY, DATE_YESTERDAY, ZUP_1C_DIR, SHARE_1C_DIR
@@ -52,7 +59,9 @@ def copy_1c_files_from_share():
local_target_path = os.path.join(ZUP_1C_DIR, filename) local_target_path = os.path.join(ZUP_1C_DIR, filename)
try: try:
shutil.copy2(remote_file, local_target_path) # shutil.copyfile копирует только содержимое потока байтов
# без попыток изменить POSIX-права/атрибуты (chmod) на CIFS/SMB шаре
shutil.copyfile(remote_file, local_target_path)
print(f" [✓] Успешно скопирован с шары: {filename} -> data/1c/") print(f" [✓] Успешно скопирован с шары: {filename} -> data/1c/")
copied_count += 1 copied_count += 1
except Exception as e: except Exception as e:
View File
+89
View File
@@ -0,0 +1,89 @@
"""
===============================================================================
FILE: services/snapshots/finder.py
ROLE: Поиск ближайшего снапшота в SQLite (Smart Snap-to-Grid) и On-Demand экспорт.
===============================================================================
"""
import logging
from datetime import datetime
from typing import Optional, Tuple
from core.connection import get_connection
from services.scud_export import run_export
logger = logging.getLogger("SNAPSHOT_FINDER")
def find_or_create_snapshot_for_time(
target_date_str: str,
target_time_str: str,
tolerance_minutes: int = 20,
allow_ondemand_export: bool = True
) -> Tuple[Optional[str], str]:
"""
Ищет ближайший срез за указанную дату и время (±tolerance_minutes).
Если не найден и allow_ondemand_export=True — запрашивает выгрузку из MS SQL на это время.
Возвращает: (snapshot_id, human_message)
"""
date_clean = target_date_str.replace('_', '.')
dt_target = datetime.strptime(f"{date_clean} {target_time_str}", "%d.%m.%Y %H:%M")
# 1. Поиск существующих снапшотов за эту дату в SQLite
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT DISTINCT snapshot_id, snapshot_time
FROM scud_logs
WHERE log_date = ? AND snapshot_time IS NOT NULL
""", (date_clean,))
rows = cursor.fetchall()
best_snapshot = None
min_diff_seconds = float('inf')
for snap_id, snap_time_str in rows:
try:
# Формат в базе: YYYY-MM-DD HH:MM:SS или DD.MM.YYYY HH:MM:SS
raw_time = str(snap_time_str).strip()
if '.' in raw_time.split()[0]:
dt_snap = datetime.strptime(raw_time, "%d.%m.%Y %H:%M:%S")
else:
dt_snap = datetime.strptime(raw_time, "%Y-%m-%d %H:%M:%S")
diff = abs((dt_snap - dt_target).total_seconds())
if diff < min_diff_seconds:
min_diff_seconds = diff
best_snapshot = (snap_id, snap_time_str, dt_snap)
except Exception:
continue
# Если найден срез в пределах допуска (по умолчанию 20 минут)
if best_snapshot and min_diff_seconds <= (tolerance_minutes * 60):
snap_id, snap_time, dt_s = best_snapshot
diff_mins = round(min_diff_seconds / 60)
return snap_id, f"Использован готовый срез {snap_id} за {dt_s.strftime('%H:%M')} (разница {diff_mins} мин)."
# 2. Если срез не найден и разрешен On-Demand экспорт из MS SQL Орион
if allow_ondemand_export:
logger.info(f"Снапшот на {date_clean} {target_time_str} не найден в SQLite. Запуск прямого среза из MS SQL...")
run_export(
input_date=date_clean,
debug=False,
save_xlsx=True
)
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT snapshot_id
FROM scud_logs
WHERE log_date = ?
ORDER BY id DESC LIMIT 1
""", (date_clean,))
row = cursor.fetchone()
if row:
return row[0], f"Создан новый срез {row[0]} из MS SQL на {target_time_str}."
return None, f"Срез на {date_clean} {target_time_str} не найден."
+112
View File
@@ -0,0 +1,112 @@
"""
===============================================================================
FILE: services/snapshots/retention.py
ROLE: Политика ротации и очистки промежуточных почасовых срезов в SQLite.
===============================================================================
"""
import logging
from datetime import datetime, timedelta
from typing import List, Tuple
from core.connection import get_connection
logger = logging.getLogger("SNAPSHOT_RETENTION")
def cleanup_old_intermediate_snapshots(days_to_keep_all: int = 2) -> int:
"""
Очищает промежуточные дневные срезы старше days_to_keep_all дней.
Правило сохранения для архивных дней:
- Сохраняется срез Y (23:59:59).
- Сохраняется полуденный срез (ближайший к 13:00).
- Все остальные промежуточные снапшоты удаляются из scud_logs.
Возвращает количество удаленных записей.
"""
cutoff_date = datetime.now() - timedelta(days=days_to_keep_all)
cutoff_str = cutoff_date.strftime("%Y-%m-%d")
deleted_rows_count = 0
with get_connection() as conn:
cursor = conn.cursor()
# 1. Получаем список всех уникальных дат старше cutoff_date
cursor.execute("""
SELECT DISTINCT log_date
FROM scud_logs
WHERE snapshot_time IS NOT NULL
""")
all_dates = [row[0] for row in cursor.fetchall()]
for raw_date in all_dates:
try:
# Преобразуем формат ДД.ММ.ГГГГ в объект даты
d_clean = raw_date.replace('_', '.')
dt_obj = datetime.strptime(d_clean, "%d.%m.%Y")
if dt_obj >= cutoff_date:
continue # Свежие дни не трогаем — там хранятся все срезы
# 2. Для архивной даты ищем все снапшоты
cursor.execute("""
SELECT DISTINCT snapshot_id, snapshot_time
FROM scud_logs
WHERE log_date = ?
""", (raw_date,))
snapshots = cursor.fetchall()
if len(snapshots) <= 2:
continue # Если и так 1-2 среза, ничего чистить не нужно
keep_ids = set()
# Ищем финальный срез Y / 23:59:59 / 22:00:00
for s_id, s_time in snapshots:
s_id_str = str(s_id)
s_time_str = str(s_time)
if s_id_str.startswith('Y') or '23:59:59' in s_time_str or '22:00:00' in s_time_str:
keep_ids.add(s_id)
break
# Ищем полуденный срез (ближайший к 13:00)
noon_target = datetime.strptime(f"{d_clean} 13:00:00", "%d.%m.%Y %H:%M:%S")
best_noon_id = None
min_noon_diff = float('inf')
for s_id, s_time in snapshots:
if s_id in keep_ids:
continue
try:
raw_t = str(s_time).strip()
if '.' in raw_t.split()[0]:
dt_s = datetime.strptime(raw_t, "%d.%m.%Y %H:%M:%S")
else:
dt_s = datetime.strptime(raw_t, "%Y-%m-%d %H:%M:%S")
diff = abs((dt_s - noon_target).total_seconds())
if diff < min_noon_diff:
min_noon_diff = diff
best_noon_id = s_id
except Exception:
continue
if best_noon_id:
keep_ids.add(best_noon_id)
# 3. Все снапшоты, не попавшие в keep_ids, удаляем
to_delete_ids = [s_id for s_id, _ in snapshots if s_id not in keep_ids]
for del_id in to_delete_ids:
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (del_id,))
deleted_rows_count += cursor.rowcount
logger.info(f"Удален промежуточный архивный срез {del_id} за {raw_date}")
except Exception as e:
logger.warning(f"Ошибка при обработке ротации за дату {raw_date}: {e}")
conn.commit()
if deleted_rows_count > 0:
logger.info(f"Ротация завершена: удалено {deleted_rows_count} строк промежуточных срезов.")
return deleted_rows_count
+88
View File
@@ -0,0 +1,88 @@
"""
===============================================================================
FILE: services/snapshots/service.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / snapshots
ROLE: Бизнес-логика срезов СКУД (выборка, отображение времени, удаление).
AI-CONTEXT-ANCHORS:
- ANCHOR[SNAPSHOT_GET_REGISTRY]: Выборка срезов с разметкой защищенных Y-снапшотов.
- ANCHOR[SNAPSHOT_DELETE_SAFE]: Безопасное удаление дневных срезов с защитой итоговых.
===============================================================================
"""
from typing import Dict, Any, Optional, List
from core.connection import get_connection
from core.repositories.scud_repo import get_available_snapshots, delete_snapshot_by_id
# ANCHOR[SNAPSHOT_GET_REGISTRY]
def get_snapshots_registry(date_str: Optional[str] = None) -> Dict[str, Any]:
"""
Возвращает реестр снапшотов.
В поле snapshot_time объединяет время среза и фактическое время создания снапшота.
"""
clean_date = date_str.strip() if date_str else ""
rows = get_available_snapshots(date_str=clean_date if clean_date else None)
snapshots = []
for r in rows:
snap_id = r[0]
log_date = r[1]
snap_time = r[2]
rec_count = r[3]
created_at = r[4] if len(r) > 4 else None
slice_time_str = snap_time.split()[1] if snap_time and " " in snap_time else snap_time
created_str = ""
if created_at and " " in str(created_at):
c_date, c_time = str(created_at).split()[:2]
c_parts = c_date.split("-")
c_date_fmt = f"{c_parts[2]}.{c_parts[1]}" if len(c_parts) == 3 else c_date
created_str = f" · создан {c_date_fmt} {c_time[:5]}"
display_label = f"Срез {slice_time_str}{created_str}"
snapshots.append({
"snapshot_id": snap_id,
"log_date": log_date,
"snapshot_time": display_label,
"record_count": rec_count,
"is_final": str(snap_id).startswith("Y") or "_FINAL" in str(snap_id)
})
return {
"query_date": clean_date or "все",
"snapshots_count": len(snapshots),
"snapshots": snapshots
}
# ANCHOR[SNAPSHOT_DELETE_SAFE]
def delete_snapshots_safely(snapshot_ids: List[str]) -> Dict[str, Any]:
"""
Удаляет выбранные дневные снапшоты.
Итоговые вечерние срезы с префиксом 'Y' гарантированно защищены от удаления.
"""
if not snapshot_ids:
return {"status": "error", "message": "Не указаны ID снапшотов для удаления."}
safe_ids = [str(s).strip() for s in snapshot_ids if s and not str(s).strip().startswith("Y")]
if not safe_ids:
return {"status": "error", "message": "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты."}
with get_connection() as conn:
cursor = conn.cursor()
placeholders = ",".join(["?"] * len(safe_ids))
cursor.execute(f"DELETE FROM scud_logs WHERE snapshot_id IN ({placeholders})", safe_ids)
deleted_count = cursor.rowcount
conn.commit()
return {
"status": "success",
"deleted_count": deleted_count,
"deleted_ids": safe_ids,
"message": f"Успешно удалено снапшотов: {len(safe_ids)} шт."
}
View File
+72
View File
@@ -0,0 +1,72 @@
"""
===============================================================================
FILE: services/tasks/exporter.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / tasks
ROLE: Экспорт задач в чистый Markdown и генерация прямой ссылки на скачивание.
===============================================================================
"""
import os
import uuid
from typing import Dict, Any, Optional
from config import BASE_DIR
from .repository import repo_get_tasks
WEB_OUTPUT_DIR = os.path.join(BASE_DIR, "output", "web", "db_export_tasks_markdown")
def export_tasks_to_markdown(
user_id: int,
filename: Optional[str] = "ROADMAP.md",
status_filter: Optional[str] = None
) -> Dict[str, Any]:
tasks = repo_get_tasks(user_id=user_id, status=status_filter)
safe_filename = os.path.basename(filename or "ROADMAP.md")
if not safe_filename.endswith(".md"):
safe_filename += ".md"
session_uuid = str(uuid.uuid4())[:8]
target_dir = os.path.join(WEB_OUTPUT_DIR, session_uuid)
os.makedirs(target_dir, exist_ok=True)
target_filepath = os.path.join(target_dir, safe_filename)
lines = [
f"# 📋 Реестр задач проекта ({safe_filename})",
f"**Всего задач:** {len(tasks)} ",
f"**Пользователь ID:** {user_id} ",
"",
"| ID | Статус | Приоритет | Модуль | Срок | Задача |",
"| :--- | :--- | :--- | :--- | :--- | :--- |"
]
status_icons = {
"IN_PROGRESS": "⚙️ В работе",
"COMPLETED": "✓ Завершено",
"BACKLOG": "📋 Бэклог"
}
for t in tasks:
t_id = t.get("task_id") or f"#{t.get('id')}"
t_status = status_icons.get(t.get("status"), t.get("status", "BACKLOG"))
t_prio = t.get("priority", "MEDIUM")
t_mod = t.get("module", "general")
t_due = t.get("due_date") or "—"
t_title = str(t.get("title", "")).replace("|", "\\|").strip()
lines.append(f"| `{t_id}` | {t_status} | {t_prio} | `{t_mod}` | {t_due} | {t_title} |")
lines.append("")
content = "\n".join(lines)
with open(target_filepath, "w", encoding="utf-8") as f:
f.write(content)
download_url = f"/api/v1/files/download/db_export_tasks_markdown/{session_uuid}/{safe_filename}"
return {
"status": "success",
"message": f"Отчет успешно сформирован в файл `{safe_filename}` (всего задач: {len(tasks)}).",
"filename": safe_filename,
"download_url": download_url,
"tasks_count": len(tasks)
}
+155
View File
@@ -0,0 +1,155 @@
"""
===============================================================================
FILE: services/tasks/repository.py
===============================================================================
"""
import re
from typing import List, Dict, Any, Optional
from core.connection import get_connection
def normalize_task_id(task_id_input: str) -> str:
if not task_id_input:
return ""
clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "").replace("#", "")
if clean_id.isdigit():
num = int(clean_id)
return f"TASK-{num:02d}" if num < 100 else f"TASK-{num:03d}"
return f"TASK-{clean_id}"
def repo_get_tasks(user_id: int, status: Optional[str] = None) -> List[Dict[str, Any]]:
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
if status and status.upper() != "ALL":
target_status = status.upper()
if target_status in ["PROGRESS", "В РАБОТЕ"]:
target_status = "IN_PROGRESS"
elif target_status in ["DONE", "ГОТОВО"]:
target_status = "COMPLETED"
elif target_status in ["PLANNED", "ПЛАНЫ"]:
target_status = "BACKLOG"
cursor.execute("""
SELECT id, task_id, module, title, priority, status, due_date, created_at
FROM tasks
WHERE user_id = ? AND (status = ? OR (status = 'BACKLOG' AND ? = 'PLANNED'))
ORDER BY id DESC
""", (user_id, target_status, target_status))
else:
cursor.execute("""
SELECT id, task_id, module, title, priority, status, due_date, created_at
FROM tasks
WHERE user_id = ?
ORDER BY id DESC
""", (user_id,))
rows = cursor.fetchall()
return [dict(r) for r in rows]
def repo_add_task(
user_id: int,
module: str,
title: str,
priority: str = "MEDIUM",
due_date: Optional[str] = None,
status: str = "BACKLOG"
) -> Dict[str, Any]:
target_status = status.upper() if status else "BACKLOG"
if target_status in ["PROGRESS", "В РАБОТЕ"]:
target_status = "IN_PROGRESS"
elif target_status in ["DONE", "ГОТОВО"]:
target_status = "COMPLETED"
elif target_status in ["PLANNED", "ПЛАНЫ", "BACKLOG"]:
target_status = "BACKLOG"
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT MAX(id) FROM tasks")
max_id = cursor.fetchone()[0] or 0
new_task_id = f"TASK-{(max_id + 1):02d}"
cursor.execute("""
INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (new_task_id, module or "general", title.strip(), priority.upper(), target_status, due_date, user_id))
conn.commit()
return {"status": "success", "task_id": new_task_id, "id": max_id + 1}
def repo_update_task(
user_id: int,
task_id: str,
title: Optional[str] = None,
priority: Optional[str] = None,
status: Optional[str] = None,
due_date: Optional[str] = None
) -> Dict[str, Any]:
clean_num = re.sub(r'\D', '', str(task_id))
formatted_id = normalize_task_id(task_id)
updates = []
params = []
if title is not None and title.strip():
updates.append("title = ?")
params.append(title.strip())
if priority is not None and priority.strip():
updates.append("priority = ?")
params.append(priority.strip().upper())
if status is not None and status.strip():
target_status = status.strip().upper()
if target_status in ["PROGRESS", "В РАБОТЕ"]:
target_status = "IN_PROGRESS"
elif target_status in ["DONE", "ГОТОВО"]:
target_status = "COMPLETED"
elif target_status in ["PLANNED", "ПЛАНЫ"]:
target_status = "BACKLOG"
updates.append("status = ?")
params.append(target_status)
if due_date is not None:
updates.append("due_date = ?")
params.append(due_date.strip() if due_date.strip() else None)
if not updates:
return {"status": "success", "message": "Нет данных для обновления"}
params.extend([clean_num, formatted_id, f"%{task_id.strip()}", user_id])
sql = f"""
UPDATE tasks
SET {', '.join(updates)}
WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
"""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(sql, params)
rows_affected = cursor.rowcount
conn.commit()
if rows_affected == 0:
return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"}
return {"status": "success", "message": f"Задача #{task_id} успешно обновлена"}
def repo_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
clean_num = re.sub(r'\D', '', str(task_id))
formatted_id = normalize_task_id(task_id)
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
DELETE FROM tasks
WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
""", (clean_num, formatted_id, f"%{task_id.strip()}", user_id))
deleted = cursor.rowcount
conn.commit()
if deleted == 0:
return {"error": f"Задача {task_id} не найдена"}
return {"status": "success", "message": f"Задача #{task_id} удалена"}
+72
View File
@@ -0,0 +1,72 @@
"""
===============================================================================
FILE: services/tasks/service.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / tasks
ROLE: Единый доменный сервис задач (бизнес-логика и диспетчер операций).
AI-CONTEXT-ANCHORS:
- ANCHOR[TASK_SERVICE_DISPATCHER]: Маршрутизация действий ADD/UPDATE/DELETE/EXPORT.
===============================================================================
"""
from typing import Dict, Any, Optional, List
from .repository import repo_get_tasks, repo_add_task, repo_update_task, repo_delete_task
from .exporter import export_tasks_to_markdown
def get_tasks(user_id: int, status: Optional[str] = None) -> List[Dict[str, Any]]:
"""Получить список задач."""
return repo_get_tasks(user_id, status)
def add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None, status: str = "BACKLOG") -> Dict[str, Any]:
"""Создать задачу."""
res = repo_add_task(user_id, module, title, priority, due_date, status)
return {"status": "success", "task_id": res["task_id"], "message": f"Задача #{res['id']} создана и добавлена в планы"}
def update_task_details(user_id: int, task_id: str, title: Optional[str] = None, priority: Optional[str] = None, status: Optional[str] = None, due_date: Optional[str] = None) -> Dict[str, Any]:
"""Обновить задачу."""
return repo_update_task(user_id, task_id, title, priority, status, due_date)
def delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
"""Удалить задачу."""
return repo_delete_task(user_id, task_id)
# ANCHOR[TASK_SERVICE_DISPATCHER]
def execute_task_action(
user_id: int,
action: str,
task_id: Optional[str] = None,
title: Optional[str] = None,
priority: Optional[str] = "MEDIUM",
status: Optional[str] = None,
module: Optional[str] = "general",
due_date: Optional[str] = None,
filename: Optional[str] = "ROADMAP.md"
) -> Dict[str, Any]:
"""Консолидированный диспетчер операций над задачами."""
act = (action or "").strip().upper()
if act == "ADD":
if not title:
return {"status": "error", "message": "Для создания задачи требуется указать title"}
return add_task(user_id, module or "general", title, priority or "MEDIUM", due_date, status or "BACKLOG")
elif act == "UPDATE":
if not task_id:
return {"status": "error", "message": "Для обновления требуется указать task_id"}
return update_task_details(user_id, str(task_id), title, priority, status, due_date)
elif act == "DELETE":
if not task_id:
return {"status": "error", "message": "Для удаления требуется указать task_id"}
return delete_task(user_id, str(task_id))
elif act == "EXPORT":
return export_tasks_to_markdown(user_id, filename=filename or "ROADMAP.md", status_filter=status)
return {"status": "error", "message": f"Неизвестное действие action='{action}'"}
+53 -60
View File
@@ -5,85 +5,75 @@ from services.ai_verifier import ask_ollama
from services.knowledge_base import load_knowledge_base from services.knowledge_base import load_knowledge_base
def find_python_fuzzy_matches(unexplained_df, raw_absent_df): def find_ai_identity_suggestions(unexplained_df, raw_staff_df):
""" """
Точный поиск совпадений ФИО силами Python (без галлюцинаций ИИ). ⭐️ Теневой ИИ-арбитраж: ищет потенциальные опечатки операторов СКУД
Сравнивает фамилию и полное имя с порогом сходства >= 0.75. среди нераспознанных сотрудников и формирует рекомендации для администратора.
""" """
if unexplained_df.empty or raw_absent_df is None or raw_absent_df.empty: if unexplained_df.empty or raw_staff_df is None or raw_staff_df.empty:
return [] return []
absent_dict = {} staff_fios = raw_staff_df['fio_clean'].dropna().unique().tolist() if 'fio_clean' in raw_staff_df.columns else []
for idx, r in raw_absent_df.iterrows(): unexp_fios = unexplained_df['fio_clean'].dropna().unique().tolist() if 'fio_clean' in unexplained_df.columns else []
raw_fio = str(r.get('ФИО', ''))
clean_fio = str(r.get('fio_clean', ''))
reason = str(r.get('Вид_отсутствия', ''))
if clean_fio and clean_fio not in ['Ао "Ленморниипроект"', 'Сотрудник', 'Nan', 'None']:
absent_dict[clean_fio] = {'raw_fio': raw_fio, 'reason': reason}
matches = [] suggestions = []
unexplained_fios = unexplained_df['fio_clean'].unique() if 'fio_clean' in unexplained_df.columns else [] for scud_fio in unexp_fios:
f_parts = scud_fio.split()
for fio in unexplained_fios: if not f_parts:
fio_parts = fio.split()
if not fio_parts:
continue continue
surname = fio_parts[0].lower() surname = f_parts[0].lower()
for abs_clean, abs_info in absent_dict.items(): for staff_fio in staff_fios:
abs_parts = abs_clean.split() if staff_fio.lower().startswith(surname[:4]):
if not abs_parts: ratio = difflib.SequenceMatcher(None, scud_fio.lower(), staff_fio.lower()).ratio()
continue if 0.75 <= ratio < 1.0:
abs_surname = abs_parts[0].lower() suggestions.append({
"scud_fio": scud_fio,
if surname == abs_surname: "suggested_zup_fio": staff_fio,
ratio = difflib.SequenceMatcher(None, fio.lower(), abs_clean.lower()).ratio() "confidence": round(ratio, 2)
if ratio >= 0.75:
matches.append({
"target_fio": fio,
"found_in_1c_raw": abs_info['raw_fio'],
"reason_1c": abs_info['reason']
}) })
break break
return matches return suggestions
def generate_markdown_report(merged_df, absent_explained, absent_unexplained, scud_present_but_absent_in_1c, anomalies_list=None, raw_scud_df=None, raw_staff_df=None, raw_absent_df=None, date_str="05.08.2026"): def generate_markdown_report(merged_df, absent_explained, absent_unexplained, scud_present_but_absent_in_1c, anomalies_list=None, raw_scud_df=None, raw_staff_df=None, raw_absent_df=None, date_str="05.08.2026"):
# 1. Загрузка Базы Знаний
kb = load_knowledge_base() kb = load_knowledge_base()
custom_rules = kb.get("rules", []) custom_rules = kb.get("rules", [])
custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет." custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет."
# 2. Предварительный расчет ключевых метрик (РЕШАЕТ NameError: total_staff)
total_staff = len(merged_df) total_staff = len(merged_df)
is_working_mask = (merged_df['Пришел'] == True) | ( present_office_cnt = len(merged_df[merged_df['Пришел'] == True])
merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False)
) reason_series = merged_df['Вид_отсутствия'].astype(str).str.lower()
present_cnt = len(merged_df[is_working_mask]) is_remote_reason = reason_series.str.contains('удален|дистанцион', regex=True, na=False)
explained_cnt = len(absent_explained) remote_home_cnt = len(merged_df[(merged_df['Пришел'] == False) & is_remote_reason])
explained_cnt = len(merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna()) & (~is_remote_reason) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))])
unexplained_cnt = len(absent_unexplained) unexplained_cnt = len(absent_unexplained)
anomalies_cnt = len(anomalies_list) if anomalies_list else 0 anomalies_cnt = len(anomalies_list) if anomalies_list else 0
# 3. Точный расчет неточных совпадений через Python # ⭐️ Теневые подсказки ИИ
fio_mismatches_python = find_python_fuzzy_matches(absent_unexplained, raw_absent_df) ai_suggestions = find_ai_identity_suggestions(absent_unexplained, raw_staff_df)
anomalies_formatted = []
if anomalies_list:
for idx, a in enumerate(anomalies_list, 1):
anomalies_formatted.append(f"{idx}. {a.get('fio', 'Сотрудник')}: {a.get('description', '')}")
anomalies_text_block = "\n".join(anomalies_formatted) if anomalies_formatted else "Аномалий не обнаружено."
# 4. Формирование списка неизвестных с приоритетом аббревиатуры СКУД
unexplained_list = [] unexplained_list = []
if not absent_unexplained.empty: if not absent_unexplained.empty:
for idx, r in absent_unexplained.iterrows(): for idx, r in absent_unexplained.iterrows():
fio = r.get('Сотрудник', r.get('fio_clean', '')) fio = r.get('Сотрудник', r.get('fio_clean', ''))
dept_scud = str(r.get('department_scud', '')).strip() dept_scud = str(r.get('department_scud', '')).strip()
dept_1c = str(r.get('Подразделение', '')).strip() dept_1c = str(r.get('Подразделение', '')).strip()
dept = dept_scud if (dept_scud and dept_scud.lower() != 'nan' and dept_scud != '—') else dept_1c dept = dept_scud if (dept_scud and dept_scud.lower() != 'nan' and dept_scud != '—') else dept_1c
if not dept: if not dept:
dept = '—' dept = '—'
pos = r.get('Должность', '—') pos = r.get('Должность', '—')
unexplained_list.append(f"{fio} — {dept}, {pos}") unexplained_list.append(f"{fio} — {dept}, {pos}")
# 5. Сборка промпта для Ollama
prompt = f""" prompt = f"""
Ты — старший аудитор кадровой безопасности и контроллинга СКУД. Ты — старший аудитор кадровой безопасности и контроллинга СКУД.
Сформируй итоговую сводку кадрового контроля на {date_str}. Сформируй итоговую сводку кадрового контроля на {date_str}.
@@ -93,50 +83,53 @@ def generate_markdown_report(merged_df, absent_explained, absent_unexplained, sc
🚨 ВХОДНЫЕ МЕТРИКИ: 🚨 ВХОДНЫЕ МЕТРИКИ:
- Всего сотрудников: {total_staff} - Всего сотрудников: {total_staff}
- Работают (офис / удаленка / командировки): {present_cnt} - Итого на работе (в офисе): {present_office_cnt}
- В том числе на удаленной работе: {remote_home_cnt}
- Официально отсутствуют: {explained_cnt} - Официально отсутствуют: {explained_cnt}
- Неизвестно (истинно неотмеченные): {unexplained_cnt} - Неизвестно (истинно неотмеченные): {unexplained_cnt}
- Выявлено аномалий/конфликтов реестров: {anomalies_cnt} - Выявлено аномалий/конфликтов реестров: {anomalies_cnt}
--- ---
🚨 ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ ({anomalies_cnt} шт): 🚨 ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ ({anomalies_cnt} шт):
{json.dumps(anomalies_list, ensure_ascii=False, indent=2)} {anomalies_text_block}
--- ---
⚠️ ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО В 1С ({len(fio_mismatches_python)} шт): 💡 ПРЕДЛОЖЕНИЯ ИИ ПО СОПОСТАВЛЕНИЮ ФИО ({len(ai_suggestions)} шт):
{json.dumps(fio_mismatches_python, ensure_ascii=False, indent=2)} {json.dumps(ai_suggestions, ensure_ascii=False, indent=2)}
--- ---
📊 СПИСОК НЕИЗВЕСТНЫХ СЛУЧАЕВ ({unexplained_cnt} чел): 📊 СПИСОК НЕИЗВЕСТНЫХ СЛУЧАЕВ ({unexplained_cnt} чел):
{json.dumps(unexplained_list, ensure_ascii=False, indent=2)} {json.dumps(unexplained_list, ensure_ascii=False, indent=2)}
СТРОГИЕ ИНСТРУКЦИИ ДЛЯ ИИ: СТРОГИЕ ИНСТРУКЦИИ:
1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО придумывать несуществующие совпадения ФИО или приписывать суффиксы "(осн.)". Используй ТОЛЬКО массив `ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО`. Если этот массив пуст, напиши в этом разделе: "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено." 1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО выводить JSON! В блоке "Выявленные ИИ аномалии" выведи читаемый нумерованный список строк ровно так, как передано в разделе "ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ".
2. В разделе "Неизвестные случаи" выведи НУМЕРОВАННЫЙ СПИСОК всех {unexplained_cnt} человек ровно в том виде, в котором они переданы выше. 2. В разделе "Подозрения на ошибки сопоставления ФИО" выведи рекомендации по сопоставлению из массива "ПРЕДЛОЖЕНИЯ ИИ". Если массив пуст — напиши "Ошибок сопоставления ФИО не обнаружено."
3. В разделе "Рекомендации" дай 2-3 конкретные системные рекомендации для кадровой службы. НЕ ПЕРЕЧИСЛЯЙ конкретные ФИО в тексте рекомендаций. 3. В разделе "Неизвестные случаи" выведи нумерованный список всех {unexplained_cnt} человек.
4. В разделе "Рекомендации" дай 2-3 системные рекомендации.
СТРОГИЙ ШАБЛОН ОТВЕТА: СТРОГИЙ ШАБЛОН ОТВЕТА:
**Сводка контроллинга СКУД и 1С:ЗУП на {date_str}** **Сводка контроллинга СКУД и 1С:ЗУП на {date_str}**
- Всего офисных сотрудников: **{total_staff}** - Всего офисных сотрудников: **{total_staff}**
- Работают (офис / удаленка / командировки): **{present_cnt}** - Итого на работе (в офисе): **{present_office_cnt}**
- В том числе на удаленной работе: **{remote_home_cnt}**
- Официально отсутствуют: **{explained_cnt}** - Официально отсутствуют: **{explained_cnt}**
- Неизвестно (истинно неотмеченные): **{unexplained_cnt}** чел. - Неизвестно (истинно неотмеченные): **{unexplained_cnt}** чел.
- Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт. - Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт.
#### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}): #### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}):
(Описание аномалий из anomalies_list. Если их нет — "Аномалий не обнаружено.") (Список аномалий или 'Аномалий не обнаружено.')
#### ⚠️ Подозрения на ошибки сопоставления ФИО и несоответствия 1С: #### 💡 Подозрения на ошибки сопоставления ФИО и предложения ИИ:
(Выведи данные ИСКЛЮЧИТЕЛЬНО из массива fio_mismatches_python. Если он пуст — "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено.") (Список предложений сопоставления или 'Ошибок сопоставления ФИО не обнаружено.')
#### Неизвестные случаи: {unexplained_cnt} #### Неизвестные случаи: {unexplained_cnt}
(Выведи нумерованный список всех {unexplained_cnt} человек) (Нумерованный список всех {unexplained_cnt} человек)
### Точечные рекомендации: ### Точечные рекомендации:
(2-3 системные рекомендации без указания ФИО сотрудников) (2-3 системные рекомендации)
""" """
sys_prompt = "Ты — русскоязычный кадровый аудитор. Пиши СТРОГО на русском языке по предоставленному Markdown-шаблону. Категорически запрещено выводить JSON или китайские символы." sys_prompt = "Ты — русскоязычный кадровый аудитор. Пиши СТРОГО на русском языке форматированным текстом. Запрещено выводить фигурные скобки JSON."
return ask_ollama(prompt, system_prompt=sys_prompt) return ask_ollama(prompt, system_prompt=sys_prompt)
+1 -1
View File
@@ -87,7 +87,7 @@ def fetch_zup_absences_from_sql(target_date) -> pd.DataFrame:
try: try:
conn_str = get_zup_connection_string() conn_str = get_zup_connection_string()
with pyodbc.connect(conn_str, timeout=5) as conn: with pyodbc.connect(conn_str, timeout=30) as conn:
df = pd.read_sql(query, conn, params=[target_date]) df = pd.read_sql(query, conn, params=[target_date])
if not df.empty: if not df.empty:

Some files were not shown because too many files have changed in this diff Show More