70 changed files with 9182 additions and 5806 deletions
+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
import pandas as pd
from datetime import datetime
from config import DATA_DIR
DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
def get_connection():
"""Создает подключение к базе данных SQLite с оптимизированными настройками."""
conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA foreign_keys = ON;")
conn.execute("PRAGMA journal_mode = WAL;")
return conn
def init_db():
"""Создает структуру таблиц и индексов в базе данных."""
with get_connection() as conn:
cursor = conn.cursor()
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
);
""")
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
"""
===============================================================================
FILE: core/database.py
ROLE: Фасад ядра базы данных с полной обратной совместимостью импортов.
===============================================================================
"""
from core.connection import get_connection, DB_PATH
from core.schema import init_all_tables
from core.repositories.scud_repo import (
has_scud_logs_for_date,
has_yesterday_final_snapshot,
get_or_create_snapshot_id,
save_scud_to_db,
get_latest_snapshot_time,
load_scud_from_db_by_snapshot,
get_available_snapshots,
delete_snapshot_by_id,
delete_snapshots_by_date
)
from core.repositories.zup_repo import (
save_staff_to_db,
load_staff_from_db,
save_absences_to_db,
load_absences_from_db,
save_anomalies_to_db,
get_all_rules_from_db,
add_rule_to_db,
get_department_synonyms_dict,
add_department_synonym_to_db
)
init_db = init_all_tables
+199
View File
@@ -0,0 +1,199 @@
"""
===============================================================================
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_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:
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")
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: pd.DataFrame, date_str: str, snapshot_time: str = None, is_yesterday: bool = False) -> None:
if df_scud is None or df_scud.empty:
return
if not snapshot_time:
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
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
)
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
)
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%' ORDER BY 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 id DESC LIMIT 1", (date_str,))
row = cursor.fetchone()
if row and row[0]:
df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(row[0],))
if not df.empty:
rename_map = {
'department': 'Подразделение', 'position': 'Должность', 'fio': 'Сотрудник',
'time_in': 'Начало_дня', 'first_activity': 'Первая_активность', 'time_out': 'Конец_дня',
'duration': 'Находился_в_здании', 'is_present': 'Пришел'
}
df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
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
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
+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()
+151
View File
@@ -0,0 +1,151 @@
"""
===============================================================================
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
);
""")
# 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 system_prompt_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
prompt_name TEXT DEFAULT 'main_agent',
section_id INTEGER NOT NULL,
item_id INTEGER NOT NULL,
content TEXT NOT NULL,
is_active INTEGER DEFAULT 1,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(prompt_name, section_id, item_id)
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS session_states (
session_id TEXT PRIMARY KEY,
state_type TEXT NOT NULL,
pending_data TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
is_ephemeral INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 5. Задачи
cursor.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT,
module TEXT DEFAULT 'general',
title TEXT NOT NULL,
priority TEXT DEFAULT 'MEDIUM',
status TEXT DEFAULT 'BACKLOG',
due_date TEXT,
user_id INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 6. Индексы
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_fio ON scud_logs(fio_clean);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_snapshot ON scud_logs(snapshot_time);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);")
conn.commit()
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -2
View File
@@ -10,5 +10,4 @@ fio,reason,note
Незнанова Валерия Игоревна,Удаленная работа,Постоянная удаленка
Ковалев Владимир Владимирович,Удаленная работа,Постоянная удаленка
Кожокарь Татьяна Юрьевна,Удаленная работа,Постоянная удаленка
Субетто Юлия Викторовна,Удаленная работа,Постоянная удаленка
Ароян Станислав Месропович,Удаленная работа,Временная удаленка
Субетто Юлия Викторовна,Удаленная работа,Постоянная удаленка
1 fio reason note
10 Незнанова Валерия Игоревна Удаленная работа Постоянная удаленка
11 Ковалев Владимир Владимирович Удаленная работа Постоянная удаленка
12 Кожокарь Татьяна Юрьевна Удаленная работа Постоянная удаленка
13 Субетто Юлия Викторовна Удаленная работа Постоянная удаленка
Ароян Станислав Месропович Удаленная работа Временная удаленка
+88 -13
View File
@@ -1,37 +1,112 @@
# 📋 История изменений (CHANGELOG)
Все ключевые изменения архитектуры, инструментов и модулей проекта SCUD Orion AI фиксируются в данном файле.
Все ключевые изменения архитектуры, инструментов и модулей проекта SCUD Orion AI фиксируются в данном файле[cite: 6].
## [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` и сброса стейтов сессий.
### 🔧 Изменено
* **`modules/web_api/llm/agent.py`**:
- Прямой просмотр промпта (`db_get_system_prompt`) переведен в прозрачный режим без блокировки строки ввода и без лишних кнопок.
- В инструмент `db_prompt_node_edit` добавлена передача исходного `baseline_prompt` для точного сопоставления правок в UI.
* **`modules/web_api/llm/core/fast_path.py`**: Оптимизирована зачистка эфемерных сообщений при кликах на кнопки фазы follow-up.
---
## [2026-08-17] — Внедрение CLI-инспекции контекста и переход на нативный Function Calling
### ✨ Добавлено
* **`scripts/db_cli.py`**: Добавлена команда `context` для инспекции истории сообщений `chat_messages` по сессиям с визуальным отображением флагов `[ЭФЕМЕРНОЕ]`.
* **`ROADMAP.md`**: Сформирована актуальная дорожная карта с планом отказа от регулярных выражений в пользу нативного Function Calling.
* **`scripts/db_cli.py`**: Добавлена команда `context` для инспекции истории сообщений `chat_messages` по сессиям с визуальным отображением флагов `[ЭФЕМЕРНОЕ]`[cite: 6].
* **`ROADMAP.md`**: Сформирована актуальная дорожная карта с планом отказа от регулярных выражений в пользу нативного Function Calling[cite: 6].
### 🔧 Изменено
* **`modules/web_api/llm/db_tools.py`**: Удалены устаревшие неиспользуемые функции и заглушки счетчиков простоя.
* **`modules/web_api/static/js/chat/core.js`**: Внедрена поддержка вызова инлайн-редактора черновика промпта прямо из окна диалога.
* **`modules/web_api/llm/db_tools.py`**: Удалены устаревшие неиспользуемые функции и заглушки счетчиков простоя[cite: 6].
* **`modules/web_api/static/js/chat/core.js`**: Внедрена поддержка вызова инлайн-редактора черновика промпта прямо из окна диалога[cite: 6].
---
## [2026-08-15] — Реляционная архитектура системного промпта
### ✨ Добавлено
* **`system_prompt_nodes`**: Создана таблица реляционных узлов промпта с индексацией по разделам и пунктам (`section_id`, `item_id`).
* **`db_prompt_node_edit`**: Добавлен инструмент точечного добавления, изменения и удаления пунктов системного промпта.
* **Инлайн-редактор**: Добавлен визуальный diff-просмотр изменений с подсветкой и кнопкой `[✏️ Редактировать]`.
* **`system_prompt_nodes`**: Создана таблица реляционных узлов промпта с индексацией по разделам и пунктам (`section_id`, `item_id`)[cite: 6].
* **`db_prompt_node_edit`**: Добавлен инструмент точечного добавления, изменения и удаления пунктов системного промпта[cite: 6].
* **Инлайн-редактор**: Добавлен визуальный diff-просмотр изменений с подсветкой и кнопкой `[✏️ Редактировать]`[cite: 6].
### 🔧 Изменено
* **`modules/web_api/llm/agent.py`**: Генерация активного системного промпта переведена на динамическую сборку из реляционных узлов SQLite.
* **`modules/web_api/llm/core/fast_path.py`**: Обработка кнопок «Подтвердить» и «Отменить» переведена на атомарную запись узлов в базу данных.
* **`modules/web_api/llm/agent.py`**: Генерация активного системного промпта переведена на динамическую сборку из реляционных узлов SQLite[cite: 6].
* **`modules/web_api/llm/core/fast_path.py`**: Обработка кнопок «Подтвердить» и «Отменить» переведена на атомарную запись узлов в базу данных[cite: 6].
---
## [2026-08-10] — Механизм эфемерных сообщений и очистки диалога
### ✨ Добавлено
* **Эфемерные сообщения (`is_ephemeral`)**: Маркировка временных сервисных ответов и черновиков.
* **`db_purge_ephemeral_messages`**: Механизм физической зачистки временных записей из SQLite при завершении операций.
* **Интерактивные виджеты**: Поддержка карточек задач (`TASK_INTERACTIVE_CARD`) с кнопками быстрого действия.
* **Эфемерные сообщения (`is_ephemeral`)**: Маркировка временных сервисных ответов и черновиков[cite: 6].
* **`db_purge_ephemeral_messages`**: Механизм физической зачистки временных записей из SQLite при завершении операций[cite: 6].
* **Интерактивные виджеты**: Поддержка карточек задач (`TASK_INTERACTIVE_CARD`) с кнопками быстрого действия[cite: 6].
-61
View File
@@ -1,61 +0,0 @@
# 🗺️ ДОРОЖНАЯ КАРТА И АРХИТЕКТУРНЫЙ РОАДМАП ПРОЕКТА SCUD ORION AI
## 📌 Зафиксированные вехи и концептуальные архитектурные решения
Схема веток Gitea для развития проекта:
main (100% стабильный релиз)
│
├──► feature/prompt-relational-nodes ──┐ (Перевод системного промпта на реляционные узлы в SQLite)
│ ▼
├──────────────────────────────────► merge to main
│
├──► feature/native-function-calling ──┐ (Отказ от регулярных выражений tool_injector.py в пользу нативного FC)
│ ▼
├──────────────────────────────────► merge to main
│
└──► feature/ephemeral-context ────────┐ (Интеллектуальный жизненный цикл эфемерных сообщений)
▼
merge to main
---
## 🎯 Текущий активный спринт: Нативный Function Calling и реляционный редактор промпта
### 1. Архитектура чистого диалогового агента без регулярных выражений
* **Концепция:** Полный отказ от жестких эвристик и регулярных выражений в `tool_injector.py`. Все решения о намерениях оператора, вызове инструментов и переключении тем принимает языковая модель через семантический контекст.
* **Ключевые этапы реализации:**
* **Этап 1 (Санитарная очистка):** Превращение `tool_injector.py` в простой фильтр мусорных тегов без инжекции и перехватов команд.
* **Этап 2 (Семантическое управление сессией):** Инструктаж модели в `role: system` о поведении в состоянии `PROMPT_PREVIEW` (оценка подтверждения, отмены, правок или смены темы диалога).
* **Этап 3 (Жизненный цикл контекста):** Модель видит временные сообщения (`is_ephemeral = 1`) только во время активной ветки; при фиксации решения (`fast_path.py`) контекст очищается через `db_purge_ephemeral_messages`.
* **Этап 4 (Гибридный UI):** Поддержка как диалоговых правок, так и прямого инлайн-редактирования через форму `[✏️ Редактировать]` в окне чата.
---
## 🚀 Масштабирование эфемерного контекста и сервисные инструменты
### 2. Модуль инспекции контекста и базы данных (`db_cli.py`)
* **Статус:** `Реализовано`.
* Добавлена команда `context [session_id] [--limit N]` для просмотра сырой таблицы сообщений `chat_messages` с пометками об эфемерности.
### 3. Модуль задач и бэклога (`tasks`)
* **Целевые инструменты:** `db_get_tasks`, `db_add_task`, `db_update_task_status`, `db_delete_task`.
* Вывод интерактивных карточек задач с фильтрами без засорения постоянной истории диалога.
### 4. Модуль срезов и архива СКУД (`snapshots`)
* **Целевые инструменты:** `db_get_snapshots`, `db_delete_snapshots`.
* Интерактивное подтверждение удаления срезов и автоматическая очистка таблиц срезов из контекста.
### 5. Модуль базы знаний, правил и аномалий (`kb & anomalies`)
* **Целевые инструменты:** `db_get_rules`, `db_get_anomalies`, `db_get_reference`.
* Изоляция нормативных справочников от постоянной истории диалога для предотвращения залипания LLM.
---
## 🔮 Долгосрочный бэклог (Future Modules)
### 6. Интерактивный модуль планирования (Checklist & Task Planner Engine)
* Динамические проектные чек-листы с синхронизацией в таблицу `tasks`.
### 7. Изолированная песочница кода (Code Execution Sandbox Engine)
* Запуск динамических Python/Pandas вычислений в изолированном Docker/gVisor контуре.
-97
View File
@@ -1,97 +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, артефакты, сгенерированные таблицы/графики) обратно в контекст модели.
### 1. Архитектура скользящего контекста и отслеживания смены темы (Intent / Topic Drift Tracking)
* **Статус:** `MVP Реализован и протестирован` (на базе инструментов системного промпта: `db_preview_prompt_merge`, `db_confirm_prompt_preview`, `db_cancel_prompt_preview`).
* **Концептуальное решение:**
1. **Двухфазное состояние сессии:** Разделение жизненного цикла на фазу черновика (`PROMPT_PREVIEW`) и фазу интерактивного подтверждения/диалога (`PROMPT_FOLLOWUP`).
2. **Эфемерный буфер:** Промежуточные экраны, подтверждения и тяжелые выводы помечаются `is_ephemeral = 1` и не засоряют постоянную историю диалога.
3. **Детерминированный счетчик (Topic Drift):** Бэкенд инкрементирует `idle_turns` при отвлечении на сторонние темы. На шаге $N = 3$ срабатывает **Context Guard** с напоминанием и кнопками действий, а при $N > 3$ происходит автоматический сброс сессии и вызов `db_purge_ephemeral_messages`.
4. **Fast-Path очистка:** Прямой перехват команд завершения (`нет, спасибо`, `закончить настройку`) моментально очищает эфемерный контекст.
---
### 1.1. План масштабирования механизма эфемерного контекста на все инструменты (Rollout Roadmap)
План:
[1. REST API (main.py)] ────► Добавляем POST, PATCH, DELETE для /api/v1/tasks
│
[2. Agent (agent.py)] ────► При db_get_tasks отдаем payload {type: "TASK_INTERACTIVE_CARD", tasks: [...]}
│
[3. UI (chat.js / CSS)] ────► Рендерим карточку с табами [Все] [В работе] [В планах] [Завершенные]
#### Этап 1. Модуль задач и бэклога (`tasks`)
* **Целевые инструменты:** `db_get_tasks`, `db_add_task`, `db_update_task_status`, `db_delete_task`.
* **Задачи реализации:**
* Перевод вывода объемных плоских списков задач в эфемерный режим (`is_ephemeral = 1`), чтобы история диалога не забивалась длинными перечнями.
* Реализация состояния сессии `TASKS_FOLLOWUP` после создания/изменения/удаления задачи.
* Интеграция с Context Guard: напоминание о завершении работы с задачами на 3-м шаге отвлечения.
* Автоочистка промежуточных диалоговых карточек задач при переключении на аналитику СКУД или общие вопросы.
#### Этап 2. Модуль срезов и архива СКУД (`snapshots`)
* **Целевые инструменты:** `db_get_snapshots`, `db_delete_snapshots`.
* **Задачи реализации:**
* Маркировка объемных таблиц срезов и логов как эфемерных сообщений.
* Внедрение интерактивного подтверждения при удалении снапшотов по ID или по дате (`SNAPSHOTS_PREVIEW` / `SNAPSHOTS_FOLLOWUP`).
* Полная автоочистка временных таблиц срезов из памяти диалога после получения пользователем нужной информации.
#### Этап 3. Модуль базы знаний, правил и аномалий (`kb & anomalies`)
* **Целевые инструменты:** `db_get_rules`, `db_get_anomalies`, `db_get_reference`.
* **Задачи реализации:**
* Вывод справочников команд, правил арбитража и списков аномалий исключительно во временный слой.
* Предотвращение «залипания» модели на старых правилах из истории: гарантия того, что LLM всегда запрашивает актуальные правила через Tool Call, а не читает их из контекста предыдущих сообщений.
#### Этап 4. Декларативная унификация в `tool_action_registry`
* **Целевые изменения:**
* Расширение схемы `tool_action_registry` новыми декларативными полями: `followup_state_type`, `auto_purge_on_exit`, `guard_question_template`.
* Устранение жестких проверок названий функций в `agent.py`: перевод всей логики жизненного цикла сессий и очистки памяти на декларативные параметры из базы данных SQLite.
+123
View File
@@ -0,0 +1,123 @@
# План реализации (ROADMAP)
## 1. Очистка от регулярок и костылей (`tool_injector.py`)
- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 4].
- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`)[cite: 4].
---
## 2. Настройка контекста и инструкций сессии (`agent.py`)
- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 4].
- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию:
- **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 4].
- **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 4].
- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 4].
- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`[cite: 4].
- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов[cite: 4].
---
## 3. Очистка эфемерных сообщений при завершении (`fast_path.py` / `context_manager.py`)
- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 4].
- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 4].
- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 4].
- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста[cite: 4].
- [x] Создать `context_manager.py` для строгого разграничения служебных tool-пар (`is_ephemeral=1`) и содержательного диалога (`is_ephemeral=0`), сохраняя беседы Topic Drift в истории[cite: 4].
---
## 4. Инлайн-редактор в окне диалога (`core.js`)
- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 4].
- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 4].
- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`)[cite: 4].
- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика[cite: 4].
---
## 5. Тестирование и валидация системного промпта
- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 4].
- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 4].
- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 4].
---
## 6. Распространение архитектурного паттерна на модуль задач (`tasks`)
- [x] **Масштабирование UI задач:**
- Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`[cite: 4].
- Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все»[cite: 4].
- Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих)[cite: 4].
- [x] **Инлайн-редактирование карточки задачи:**
- Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности)[cite: 4].
- Отображение даты создания задачи[cite: 4].
- Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`[cite: 4].
- [x] **Детерминированный Fast-Path и двухфазное удаление:**
- Мгновенная смена статусов без задержек LLM[cite: 4].
- Карточка подтверждения удаления с автоочисткой контекста[cite: 4].
- [x] **Генерация отчетов задач в Markdown:**
- Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`[cite: 3].
- Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`)[cite: 3].
- [x] **Доменная консолидация задач:**
- Реализован консолидированный инструмент `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE", "EXPORT"], ...)`[cite: 3].
---
## 7. Распространение на модуль снапшотов (`snapshots`)
- [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00[cite: 4].
- [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат[cite: 4].
- [x] **Интерактивный UI с чекбоксами и защитой срезов:**
- Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке[cite: 3].
- Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора)[cite: 3].
- Динамическая кнопка «Удалить выбранные (N)» в подвале карточки[cite: 3].
- [x] **Детерминированный Fast-Path удаления срезов:**
- Двухфазное подтверждение удаления (одиночное и пакетное) с корректным счётчиком количества удаляемых элементов[cite: 3].
- Защита от сброса фильтра даты (`query_date`) при обновлении карточки после удаления[cite: 3].
---
## 🏗️ Раздел 8. Глубокий архитектурный рефакторинг ядра и сервисов `[ЗАВЕРШЕНО]`
- [x] **Шаг 1. Выделение общего слоя ядра (`core/`)**
- [x] Изолированный пул соединений SQLite (`core/connection.py`) с поддержкой WAL и таймаутов.
- [x] DDL-схемы таблиц и индексов (`core/schema.py`).
- [x] Репозитории СКУД и 1С (`core/repositories/scud_repo.py`, `core/repositories/zup_repo.py`).
- [x] Фасад обратной совместимости (`core/database.py`).
- [x] **Шаг 2. Декомпозиция предметных доменов (`services/`)**
- [x] Сервис задач (`services/tasks/`): CRUD, валидация, markdown-экспорт (`exporter.py`).
- [x] Сервис системного промпта (`services/prompts/`): сборка узлов, вычисление diff (`diff_engine.py`), пакетное удаление (`BATCH_DELETE`).
- [x] Сервис снапшотов СКУД (`services/snapshots/`): безопасное удаление с защитой Y-снапшотов.
- [x] Сервис базы знаний (`services/knowledge/`): правила арбитража и синонимы отделов.
- [x] **Шаг 3. Рефакторинг ETL-конвейера (`services/scud_etl/`)**
- [x] Оркестратор этапов контроллинга (`pipeline.py`).
- [x] Детектор аномалий и конфликтов реестров (`anomaly_detector.py`).
- [x] Агрегация проходов и наложение исключений (`merger.py`).
- [x] Информативное консольное логирование этапов в `main_etl.py`.
- [x] **Шаг 4. Модульный ИИ-оркестратор (`modules/ai_engine/`)**
- [x] Динамический строитель системного контекста (`context_builder.py`).
- [x] Изолированные обработчики инструментов (`handlers/task_handler.py`, `handlers/prompt_handler.py`, `handlers/snapshot_handler.py`).
- [x] Компактный диспетчер диалога (`agent.py`).
- [x] Двухконтурный семантический анализатор намерений (`tool_injector.py`).
- [x] **Шаг 5. Зачистка рудиментов и стабилизация**
- [x] Устранение дубликатов в `web_api/llm/db/`.
- [x] Очистка роутеров от прямых манипуляций с базой данных.
- [x] Исправление сигнатур Fast-Path перехватчиков.
---
## 📊 Раздел 9. Диалоговая генерация и контроль отчетов СКУД (Conversational Reporting Engine) `[В ПЛАНАХ]`
- [ ] **Интерактивный запуск отчетов из чата**
- [ ] Инструмент `db_generate_report(date_str, report_type)` для генерации сводки или детального отчета по требованию оператора.
- [ ] Автоматическая отдача карточки скачивания сформированного Excel-файла прямо в диалоге (`FILE_DOWNLOAD_CARD`).
- [ ] **Диалоговый аудит расхождений**
- [ ] Инструмент точечной выборки: «Кто сегодня не пришел из отдела ОВК?», «Покажи опоздавших за вчера».
- [ ] Быстрое внесение исключений и синонимов подразделений через диалог.
---
## 10. Изолированная песочница кода (Code Execution Sandbox Engine)
- [ ] **Docker/gVisor контур:**
- Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups)[cite: 4].
- Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only[cite: 4].
- [ ] **Динамические Python/Pandas вычисления:**
- Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 4].
- Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 4].
@@ -1,33 +0,0 @@
# План реализации
## 1. Очистка от регулярок и костылей (`tool_injector.py`)
- [ ] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов.
- [ ] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`).
---
## 2. Настройка контекста и инструкций сессии (`agent.py`)
- [ ] Передать управление диалогом языковой модели через системный блок `role: "system"`.
- [ ] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию:
- **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты.
- **Смена темы:** вежливо напомнить об открытом изменении и запросить решение.
- [ ] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью.
---
## 3. Очистка эфемерных сообщений при завершении (`fast_path.py`)
- [ ] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**.
- [ ] Сбрасывать состояние сессии в базе данных после фиксации решения.
---
## 4. Инлайн-редактор в окне диалога (`core.js`)
- [ ] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**.
- [ ] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением.
---
## 5. Тестирование и валидация
- [ ] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели.
- [ ] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором.
- [ ] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста.
+56 -547
View File
@@ -1,571 +1,80 @@
"""
===============================================================================
FILE: main_etl.py
PROJECT: SCUD Orion AI (Unified Architecture)
ROLE: Главная точка входа ETL-конвейера СКУД ⟷ 1С:ЗУП.
===============================================================================
"""
import os
import sys
import json
import argparse
import pandas as pd
import logging
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.text_reporter.service import format_controlling_summary_markdown
from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR, DATA_DIR, normalize_fio
from services.scud_export import run_export
from services.share_copier import copy_1c_files_from_share
from services.data_validator import check_file_freshness
from services.data_loader import load_1c_data_smart, load_scud_data
from services.excel_exporter import generate_summary_excel, generate_detailed_excel
from services.ai_verifier import ai_verify_scud_against_staff, analyze_scud_mass_failure_ai
from services.text_reporter import generate_markdown_report
from services.feedback_loop import review_ai_decisions
from services.knowledge_base import load_knowledge_base
from core.database import (
init_db,
save_scud_to_db,
save_staff_to_db,
save_absences_to_db,
save_anomalies_to_db,
load_scud_from_db_by_snapshot,
load_staff_from_db,
load_absences_from_db,
get_latest_snapshot_time,
has_scud_logs_for_date
)
# Корректные импорты из папки services/
from services.scud_export import run_scud_export_today_and_yesterday
from services.share_copier import sync_1c_files_from_share
from services.excel_exporter import build_daily_summary_excel, build_detailed_yesterday_excel
def load_exceptions_config():
"""Загружает файл exceptions.json из корня проекта."""
root_dir = os.path.dirname(os.path.abspath(__file__))
json_path = os.path.join(root_dir, "exceptions.json")
if not os.path.exists(json_path):
return {}
try:
with open(json_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"[⚠️] Ошибка чтения exceptions.json: {e}")
return {}
def apply_exceptions_from_json(df, exceptions_cfg):
"""
Быстрая и эффективная разметка флага is_excluded=True на основе exceptions.json.
Выполняется мгновенно без цикличных HTTP-запросов к ИИ.
"""
if df is None or df.empty or not exceptions_cfg:
if df is not None:
df['is_excluded'] = False
return df
deps = [d.strip().lower() for d in exceptions_cfg.get("departments", []) if d]
exact_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p]
pos_kw = [k.strip().lower() for k in exceptions_cfg.get("position_keywords", []) if k]
exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f]
df['is_excluded'] = False
for idx, row in df.iterrows():
fio_clean = row.get('fio_clean', '')
dep_1c = str(row.get('Подразделение', '')).lower()
dep_scud = str(row.get('department_scud', row.get('department', ''))).lower()
pos = str(row.get('Должность', '')).lower()
is_fio_exc = fio_clean in exc_fios
is_pos_exc = (pos in exact_pos) or any(k in pos for k in pos_kw if k) if pos else False
# Быстрая проверка отделов по подстрокам
is_dep_exc = False
if deps:
is_dep_exc = any(d in dep_1c or d in dep_scud for d in deps)
if is_fio_exc or is_dep_exc or is_pos_exc:
df.at[idx, 'is_excluded'] = True
return df
def filter_report_dataframe(merged_df):
"""
Исключает сотрудников из списка исключений и сотрудников без пропуска из детального отчета,
ЕСЛИ у них нет официального документа отсутствия из 1С:ЗУП.
"""
if merged_df is None or merged_df.empty:
return merged_df
has_1c_reason = (
merged_df['Вид_отсутствия'].notna() &
(merged_df['Вид_отсутствия'].astype(str).str.strip() != '') &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
)
is_not_excluded = merged_df.get('is_excluded', False) == False
is_not_no_pass = merged_df.get('no_scud_pass', False) == False
filtered_df = merged_df[(is_not_excluded & is_not_no_pass) | has_1c_reason].copy()
return filtered_df
def load_static_reason_workers():
"""Загружает реестр удалёнщиков и статических причин из CSV."""
static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
if not os.path.exists(static_path):
return {}
try:
df_static = pd.read_csv(static_path, encoding='utf-8')
if 'fio' in df_static.columns and 'reason' in df_static.columns:
df_static['fio_clean'] = df_static['fio'].apply(normalize_fio)
return dict(zip(df_static['fio_clean'], df_static['reason']))
except Exception as e:
print(f"[⚠️] Ошибка чтения static_reason_workers.csv: {e}")
return {}
def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules, scud_fios_set=None):
"""
Автоматически выявляет истинные аномалии СКУД ⟷ 1С.
"""
anomalies = []
kb_rules_text = " ".join(kb_rules).lower() if kb_rules else ""
ALLOWED_WORK_TRIP_KEYWORDS = ['командировк', 'разъездн', 'поездк']
for idx, row in merged_df.iterrows():
fio = row.get('Сотрудник', row.get('fio_clean', ''))
fio_clean = row.get('fio_clean', '')
is_present = row.get('Пришел', False)
is_exc = row.get('is_excluded', False)
reason_1c = str(row.get('Вид_отсутствия', '')).strip()
has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != '' and not reason_1c.startswith('Исключение')
anom_flag = row.get('anomaly_flag', 'NONE')
is_fio_whitelisted_in_kb = fio_clean.lower() in kb_rules_text
if is_present and has_1c_reason:
reason_lower = reason_1c.lower()
is_allowed_trip = any(kw in reason_lower for kw in ALLOWED_WORK_TRIP_KEYWORDS)
if not is_allowed_trip and not is_fio_whitelisted_in_kb:
anomalies.append({
"type": "ФИЗИЧЕСКОЕ ПРИСУТСТВИЕ ПРИ ОФИЦИАЛЬНОМ ОТСУТСТВИИ",
"fio": fio,
"details": f"Сотрудник пришел по СКУД, но в 1С оформлен документ: '{reason_1c}'"
})
if is_exc and not has_1c_reason:
continue
if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY':
first_act = row.get('Первая_активность', '—')
anomalies.append({
"type": "АНОМАЛИЯ СКУД: ПЕРЕМЕЩЕНИЕ БЕЗ ВХОДА",
"fio": fio,
"details": f"Отсутствует регистрация входа на КПП при зафиксированной первой активности в {first_act}"
})
if scud_fios_set is not None:
if fio_clean not in scud_fios_set and not has_1c_reason:
anomalies.append({
"type": "АНОМАЛИЯ УЧЕТА: СОТРУДНИК ОТСУТСТВУЕТ В СКУД ОРИОН PRO",
"fio": fio,
"details": f"Сотрудник числится в Штатном расписании 1С ({row.get('Подразделение', '—')}), но полностью отсутствует в базе СКУД Орион Pro (профиль не создан или карта не выдана)"
})
return anomalies
def aggregate_scud_by_employee(df, debug=False):
"""Агрегирует проходы СКУД по уникальным сотрудникам."""
if df is None or df.empty or 'fio_clean' not in df.columns:
return df
aggregated = []
for fio_clean, group in df.groupby('fio_clean', sort=False):
is_present = group['Пришел'].any() if 'Пришел' in group.columns else False
if is_present and 'Пришел' in group.columns:
present_rows = group[group['Пришел'] == True]
best_row = present_rows.iloc[0].to_dict() if not present_rows.empty else group.iloc[0].to_dict()
else:
best_row = group.iloc[0].to_dict()
best_row['Пришел'] = is_present
aggregated.append(best_row)
return pd.DataFrame(aggregated)
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s")
def main():
help_text = """
Система автоматизированного контроллинга СКУД ⟷ 1С:ЗУП (scud_orion_ai_v2)
ПРИМЕРЫ ЗАПУСКА:
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 снапшота или время создания")
args = parser.parse_args()
DEBUG = args.debug
print("=" * 60)
print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if DEBUG else ''}")
print("ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С")
print("=" * 60)
init_db()
kb_data = load_knowledge_base()
kb_rules = kb_data.get("rules", [])
exceptions_cfg = load_exceptions_config()
now = datetime.now()
today_str = now.strftime("%d.%m.%Y")
yesterday_str = (now - timedelta(days=1)).strftime("%d.%m.%Y")
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}'")
# [Этап 0] Выгрузка свежих данных СКУД
print(f"\n[0/5] Экспорт данных СКУД за {today_str} и {yesterday_str}...")
run_scud_export_today_and_yesterday()
if not args.skip_export and not args.snapshot:
try:
run_export(save_xlsx=True, debug=DEBUG)
except Exception as e:
print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Переходим к записям в SQLite.")
else:
print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...")
# [Этап 0.5] Синхронизация файлов с шары 1С
print(f"\n[0.5/5] Проверка и копирование файлов 1С с шары...")
sync_1c_files_from_share()
# ⚡️ ПРОВЕРКА ШАРЫ ТОЛЬКО ДЛЯ ОБЫЧНОГО ДНЕВНОГО ЗАПУСКА
if not args.snapshot and not args.skip_export:
copy_1c_files_from_share()
# [Этап 1-2] Загрузка данных
print(f"\n[1-2/5] Загрузка срезов: Сегодня = {today_str}, Накануне = {yesterday_str}...")
df_scud_yesterday = load_best_snapshot_for_date(yesterday_str, prefer_final_y=True)
df_scud_today = load_best_snapshot_for_date(today_str, prefer_final_y=False)
print("[1/5] Проверка актуальности и свежести входных данных...")
is_valid, warnings, errors, has_today_1c = check_file_freshness()
df_staff_yesterday, df_abs_yesterday = load_1c_files_for_date(yesterday_str)
df_staff_today, df_abs_today = load_1c_files_for_date(today_str)
if warnings:
print("\n--- ⚠️ ПРЕДУПРЕЖДЕНИЯ ОБ АКТУАЛЬНОСТИ ---")
for w in warnings:
print(f" • {w}")
print("-" * 45)
# [Этап 3] Детальный отчет за вчера (на базе финального Y-снапшота)
print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_str})...")
df_merged_yesterday = merge_scud_and_1c(df_scud_yesterday, df_staff_yesterday, df_abs_yesterday)
build_detailed_yesterday_excel(df_merged_yesterday, yesterday_str)
if not is_valid:
print("\n" + "!" * 60)
print("🛑 ОСТАНОВКА ВЫПОЛНЕНИЯ: Отсутствуют критически важные файлы за вчерашний день!")
for e in errors:
print(f" {e}")
print("!" * 60)
sys.exit(1)
# [Этап 4] Сводка за сегодня
print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str}...")
df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today)
metrics_today = calculate_summary_metrics(df_merged_today)
anomalies_today = detect_registry_anomalies(df_merged_today)
build_daily_summary_excel(df_merged_today, metrics_today, today_str)
print("[✓] Проверка доступности данных успешно пройдена!\n")
else:
print("[1/5] Пропуск проверки сетевой шары (все данные читаются из SQLite)...")
has_today_1c = True
print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...")
# [Этап 5] Формирование текстового отчета и вывод в консоль
print(f"\n[5/5] Формирование Markdown-сводки...")
summary_md = format_controlling_summary_markdown(today_str, metrics_today, anomalies_today)
# 🎯 ВЫЧИСЛЕНИЕ ДАТ СНАПШОТА И ДАТ НАКАНУНЕ
if args.snapshot or args.skip_export:
raw_scud_today_df = load_scud_from_db_by_snapshot(None, snapshot_param=snapshot_param)
if not raw_scud_today_df.empty and 'log_date' in raw_scud_today_df.columns:
target_date_str = str(raw_scud_today_df['log_date'].iloc[0])
else:
target_date_str = DATE_TODAY
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}")
dt_target = datetime.strptime(target_date_str, "%d.%m.%Y")
if dt_target.weekday() == 0: # Понедельник -> Пятница
dt_yesterday = dt_target - timedelta(days=3)
else:
dt_yesterday = dt_target - timedelta(days=1)
yesterday_date_str = dt_yesterday.strftime("%d.%m.%Y")
print(f"[📸] СНАПШОТ ОПРЕДЕЛЕН: Целевая дата = {target_date_str}, Накануне = {yesterday_date_str}")
raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None)
# Гарантированная загрузка кадров 1С за обе даты (БД + локальный фолбэк)
df_staff_today, df_absent_today = load_1c_data_smart(target_date_str, use_db=True)
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(yesterday_date_str, use_db=True)
else:
target_date_str = DATE_TODAY
yesterday_date_str = DATE_YESTERDAY
raw_scud_today_df = load_scud_from_db_by_snapshot(target_date_str, snapshot_param=snapshot_param)
raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None)
df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY, use_db=False)
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY, use_db=False)
if df_staff_yesterday is not None:
save_staff_to_db(df_staff_yesterday, yesterday_date_str)
if df_absent_yesterday is not None:
save_absences_to_db(df_absent_yesterday, yesterday_date_str)
if df_staff_today is not None:
save_staff_to_db(df_staff_today, target_date_str)
if df_absent_today is not None:
save_absences_to_db(df_absent_today, target_date_str)
staff_fios_yesterday_clean = df_staff_yesterday['fio_clean'].dropna().tolist() if df_staff_yesterday is not None else []
static_reasons_dict = load_static_reason_workers()
# ============================================================
# 🎯 ЧАСТЬ 1: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (ДЕНЬ НАКАНУНЕ)
# ============================================================
print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_date_str})...")
if not raw_scud_yesterday_df.empty and 'Пришел' not in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['Пришел'] = raw_scud_yesterday_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_yesterday_df.columns else False
scud_yesterday_unrecognized = raw_scud_yesterday_df[~raw_scud_yesterday_df['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist() if not raw_scud_yesterday_df.empty else []
fio_mapping_scud_y = ai_verify_scud_against_staff(scud_yesterday_unrecognized, staff_fios_yesterday_clean)
if fio_mapping_scud_y:
raw_scud_yesterday_df['fio_clean'] = raw_scud_yesterday_df['fio_clean'].apply(
lambda x: fio_mapping_scud_y[x]['staff_fio'] if x in fio_mapping_scud_y else x
)
if df_absent_yesterday is not None and not df_absent_yesterday.empty:
absent_unrecognized_yesterday = df_absent_yesterday[~df_absent_yesterday['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist()
if absent_unrecognized_yesterday:
fio_mapping_absent_y = ai_verify_scud_against_staff(absent_unrecognized_yesterday, staff_fios_yesterday_clean)
if fio_mapping_absent_y:
df_absent_yesterday['fio_clean'] = df_absent_yesterday['fio_clean'].apply(
lambda x: fio_mapping_absent_y[x]['staff_fio'] if x in fio_mapping_absent_y else x
)
raw_scud_yesterday_df = aggregate_scud_by_employee(raw_scud_yesterday_df, debug=DEBUG)
# ⭐️ ФОРМИРУЕМ МЕРДЖ ИСКЛЮЧИТЕЛЬНО НА БАЗЕ ШТАТА ЗА ПРОШЛЫЙ ДЕНЬ
merged_yesterday = df_staff_yesterday.copy() if (df_staff_yesterday is not None and not df_staff_yesterday.empty) else (df_staff_today.copy() if df_staff_today is not None else pd.DataFrame())
if not merged_yesterday.empty:
if not raw_scud_yesterday_df.empty:
if 'Подразделение' in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['Подразделение']
elif 'department' in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['department']
else:
raw_scud_yesterday_df['department_scud'] = ''
merged_yesterday = merged_yesterday.merge(
raw_scud_yesterday_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']],
on='fio_clean', how='left'
)
if 'Вид_отсутствия' in merged_yesterday.columns:
merged_yesterday = merged_yesterday.drop(columns=['Вид_отсутствия'])
# Присоединяем отсутствия СТРОГО за дату yesterday_date_str
if df_absent_yesterday is not None and not df_absent_yesterday.empty:
merged_yesterday = merged_yesterday.merge(
df_absent_yesterday[['fio_clean', 'Вид_отсутствия']],
on='fio_clean',
how='left'
)
if 'Пришел' not in merged_yesterday.columns:
merged_yesterday['Пришел'] = False
else:
merged_yesterday['Пришел'] = merged_yesterday['Пришел'].fillna(False)
if 'Сотрудник' not in merged_yesterday.columns:
merged_yesterday['Сотрудник'] = merged_yesterday.get('ФИО', merged_yesterday['fio_clean'])
if static_reasons_dict:
for fio_clean, reason_val in static_reasons_dict.items():
mask_yesterday = (
(merged_yesterday['Пришел'] == False) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday['fio_clean'] == fio_clean)
)
merged_yesterday.loc[mask_yesterday, 'Вид_отсутствия'] = reason_val
merged_yesterday = apply_exceptions_from_json(merged_yesterday, exceptions_cfg)
mask_exc_yesterday = (
(merged_yesterday['Пришел'] == False) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday.get('is_excluded', False) == True)
)
merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
scud_fios_yesterday_set = set(raw_scud_yesterday_df['fio_clean'].dropna().tolist()) if not raw_scud_yesterday_df.empty else set()
merged_yesterday['no_scud_pass'] = (
(~merged_yesterday['fio_clean'].isin(scud_fios_yesterday_set)) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday.get('is_excluded', False) == False)
)
anomalies_yesterday_list = detect_all_anomalies(merged_yesterday, static_reasons_dict, kb_rules, scud_fios_set=scud_fios_yesterday_set)
save_anomalies_to_db(anomalies_yesterday_list, yesterday_date_str)
filtered_yesterday = filter_report_dataframe(merged_yesterday)
generate_detailed_excel(merged_df=filtered_yesterday, date_str=yesterday_date_str)
yesterday_dt_obj = datetime.strptime(yesterday_date_str, "%d.%m.%Y")
yesterday_22_str = yesterday_dt_obj.strftime("%Y-%m-%d 22:00:00")
save_scud_to_db(merged_yesterday, yesterday_date_str, snapshot_time=yesterday_22_str, is_yesterday=True)
print(f"[✓] Детальный отчет за вчера сформирован и зафиксирован в SQLite за {yesterday_date_str}")
# ============================================================
# 🎯 ЧАСТЬ 2: ЕЖЕДНЕВНАЯ СВОДКА (ЗА ЦЕЛЕВОЙ ДЕНЬ СНАПШОТА)
# ============================================================
if not args.snapshot and not args.skip_export:
save_scud_to_db(raw_scud_today_df, target_date_str, snapshot_time=snapshot_param, is_yesterday=False)
if has_today_1c and df_staff_today is not None and df_absent_today is not None:
print(f"\n[4/5] Обработка и построение Ежедневной сводки за {target_date_str}...")
staff_fios_today_clean = df_staff_today['fio_clean'].dropna().tolist()
if not raw_scud_today_df.empty and 'Пришел' not in raw_scud_today_df.columns:
raw_scud_today_df['Пришел'] = raw_scud_today_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_today_df.columns else False
scud_unrecognized = raw_scud_today_df[~raw_scud_today_df['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist() if not raw_scud_today_df.empty else []
fio_mapping_scud = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_today_clean)
if fio_mapping_scud:
raw_scud_today_df['fio_clean'] = raw_scud_today_df['fio_clean'].apply(
lambda x: fio_mapping_scud[x]['staff_fio'] if x in fio_mapping_scud else x
)
absent_unrecognized_today = df_absent_today[~df_absent_today['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist()
if absent_unrecognized_today:
fio_mapping_absent = ai_verify_scud_against_staff(absent_unrecognized_today, staff_fios_today_clean)
if fio_mapping_absent:
df_absent_today['fio_clean'] = df_absent_today['fio_clean'].apply(
lambda x: fio_mapping_absent[x]['staff_fio'] if x in fio_mapping_absent else x
)
raw_scud_today_df = aggregate_scud_by_employee(raw_scud_today_df, debug=DEBUG)
merged_today = df_staff_today.copy()
if not raw_scud_today_df.empty:
if 'Подразделение' in raw_scud_today_df.columns:
raw_scud_today_df['department_scud'] = raw_scud_today_df['Подразделение']
elif 'department' in raw_scud_today_df.columns:
raw_scud_today_df['department_scud'] = raw_scud_today_df['department']
else:
raw_scud_today_df['department_scud'] = ''
merged_today = merged_today.merge(
raw_scud_today_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']],
on='fio_clean', how='left'
)
if df_absent_today is not None and not df_absent_today.empty:
merged_today = merged_today.merge(df_absent_today[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left')
if 'Пришел' not in merged_today.columns:
merged_today['Пришел'] = False
else:
merged_today['Пришел'] = merged_today['Пришел'].fillna(False)
if 'Сотрудник' not in merged_today.columns:
merged_today['Сотрудник'] = merged_today.get('ФИО', merged_today['fio_clean'])
if static_reasons_dict:
for fio_clean, reason_val in static_reasons_dict.items():
mask_today = (
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_today['fio_clean'] == fio_clean)
)
merged_today.loc[mask_today, 'Вид_отсутствия'] = reason_val
merged_today = apply_exceptions_from_json(merged_today, exceptions_cfg)
mask_exc_today = (
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_today.get('is_excluded', False) == True)
)
merged_today.loc[mask_exc_today, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
scud_fios_today_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set()
merged_today['no_scud_pass'] = (
(~merged_today['fio_clean'].isin(scud_fios_today_set)) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_today.get('is_excluded', False) == False)
)
scud_fios_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set()
anomalies_list = detect_all_anomalies(merged_today, static_reasons_dict, kb_rules, scud_fios_set=scud_fios_set)
mass_failure_today = analyze_scud_mass_failure_ai(raw_scud_today_df)
if mass_failure_today and mass_failure_today.get("is_mass_failure"):
print("\n" + "!" * 60)
print(f"🚨 ВНИМАНИЕ! ИИ ОБНАРУЖИЛ ОПЕРАТИВНЫЙ СБОЙ ТУРНИКЕТОВ ВХОДА СЕГОДНЯ ({mass_failure_today['anomaly_percent']}% СМЕНЫ)")
print(mass_failure_today["alert_text"])
print("!" * 60 + "\n")
save_anomalies_to_db(anomalies_list, target_date_str)
is_no_pass_today = merged_today['no_scud_pass'] == True if 'no_scud_pass' in merged_today.columns else False
is_exc_today = merged_today.get('is_excluded', False) == True
absent_explained = merged_today[
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].notna()) &
(~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
]
absent_unexplained = merged_today[
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(~is_no_pass_today) &
(~is_exc_today)
]
scud_present_but_absent_in_1c = merged_today[
(~is_exc_today) &
(merged_today['Пришел'] == True) &
(merged_today['Вид_отсутствия'].notna()) &
(~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
]
print("[5/5] Запуск ИИ-аудитора и построение Ежедневной сводки...")
filtered_anomalies_list = [
a for a in anomalies_list
if "ОТСУТСТВУЕТ В СКУД" not in a.get('type', '')
]
report_text = generate_markdown_report(
merged_df=merged_today,
absent_explained=absent_explained,
absent_unexplained=absent_unexplained,
scud_present_but_absent_in_1c=scud_present_but_absent_in_1c,
anomalies_list=filtered_anomalies_list,
raw_scud_df=raw_scud_today_df,
raw_absent_df=df_absent_today,
date_str=target_date_str
)
generate_summary_excel(merged_df=merged_today, date_str=target_date_str)
md_report_path = os.path.join(OUTPUT_DIR, f"Сводка_контроллинга_{target_date_str}.md")
with open(md_report_path, "w", encoding="utf-8") as f:
f.write(report_text)
print(f"\n[✓] Текстовый отчет сохранен в: {md_report_path}")
suspicious_cases = []
for a in anomalies_list:
if "ОФИЦИАЛЬНОМ ОТСУТСТВИИ" not in a.get('type', ''):
suspicious_cases.append({
'fio_target': a['fio'],
'reason': f"{a['type']}: {a['details']}"
})
if suspicious_cases:
review_ai_decisions(report_text, suspicious_cases)
print("\n" + "=" * 60)
print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ-АУДИТОРА:")
print("=" * 60)
print(report_text)
else:
print(f"\n[ℹ️] Формирование Ежедневной сводки за {target_date_str} ПРОПУЩЕНО.")
print("\n" + "=" * 60)
print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ-АУДИТОРА:")
print("=" * 60)
print(summary_md)
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
}
+370 -143
View File
@@ -3,13 +3,20 @@
FILE: modules/web_api/llm/agent.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm (Core Agent Coordinator)
ROLE: Оркестратор диалога, диспетчер реляционных узлов промпта и инструментов.
ROLE: Нативный оркестратор диалога, диспетчер инструментов (Function Calling)
и управление сессионными стейтами через ContextManager.
AI-CONTEXT-ANCHORS:
- ANCHOR[AGENT_IMPORTS]: Системные и доменные импорты.
- ANCHOR[AGENT_MAIN_PIPELINE]: Основная точка входа process_chat_message.
- ANCHOR[AGENT_SYSTEM_PROMPT]: Формирование динамического контекста.
- ANCHOR[AGENT_TOOL_DISPATCHER]: Исполнение нативных вызовов инструментов.
- ANCHOR[AGENT_TOPIC_DRIFT]: Защита контекста и обработка свободных тем.
===============================================================================
"""
# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_IMPORTS]
# ANCHOR[AGENT_IMPORTS]
import sys
import re
import json
import logging
from typing import List, Dict, Any, Tuple, Optional
@@ -23,6 +30,8 @@ from .db_tools import (
db_update_task_status,
db_delete_task,
db_add_task,
db_tasks_edit,
db_export_tasks_markdown,
db_get_rules,
db_set_session_state,
db_get_session_state,
@@ -43,8 +52,13 @@ from .core.calendar_utils import get_dynamic_calendar_context
from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed
from .core.ollama_client import call_ollama_chat
from .core.fast_path import handle_fast_path_intercept
from .core.context_manager import (
save_tool_interaction,
save_dialog_interaction,
mark_last_user_message_ephemeral,
close_tool_session_and_cleanup
)
# --- [SECTION 2: LOGGING CONFIGURATION] --- # ANCHOR[LOGGING_CONFIG]
logger = logging.getLogger("SCUD_AGENT")
logger.setLevel(logging.INFO)
logger.propagate = False
@@ -56,7 +70,7 @@ if not logger.handlers:
logger.addHandler(handler)
# --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] --- # ANCHOR[CHAT_PROCESSOR]
# ANCHOR[AGENT_MAIN_PIPELINE]
def process_chat_message(
user_id: int,
user_message: str,
@@ -65,52 +79,93 @@ def process_chat_message(
chat_history: List[Dict[str, Any]] = None,
session_id: str = "web_session_main"
) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
"""Главный конвейер диалога с поддержкой реляционного редактора промпта."""
"""
Главный конвейер обработки входящего сообщения чата на базе нативного Function Calling.
"""
logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
session_state = db_get_session_state(session_id)
if not session_state:
db_purge_ephemeral_messages(session_id)
# 1. Fast-Path перехват
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
# 1. Быстрый перехват строго системных кнопок UI (подтверждение превью промпта)
fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
if fast_path_res:
return fast_path_res
# 2. Сохранение пользовательского сообщения
is_user_ephemeral = 1 if session_state else 0
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=is_user_ephemeral)
# 2. Фиксация сообщения пользователя
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
db_history = db_get_chat_history(session_id, limit=20)
# 3. Сборка системного промпта
dynamic_prompt_text = db_get_active_system_prompt()
# ANCHOR[AGENT_SYSTEM_PROMPT]
calendar_context = get_dynamic_calendar_context()
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
current_state_type = session_state.get("state_type") if session_state else None
state_data = session_state.get("data_json") or {} if session_state else {}
if not isinstance(state_data, dict):
state_data = {}
idle_turns = state_data.get("idle_turns", 0)
active_state_context = ""
if session_state:
state_type = session_state.get("state_type", "GENERAL")
active_state_context = f"\n[АКТИВНЫЙ РЕЖИМ СЕССИИ: {state_type}]\n"
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"- Если оператор запрашивает ДРУГУЮ дату или день недели, отличную от {active_date} (например: 'за вчера', 'а за 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"
)
system_prompt_content = (
f"Ты — интеллектуальный ассистент SCUD Orion AI. "
f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n"
f"СТРОГИЕ ПРАВИЛА СТИЛЯ:\n"
f"- Запрещено использовать панибратские или шутливые обращения. Отвечай профессионально и строго по существу.\n\n"
f"[ОКРУЖЕНИЕ]\n"
f"- Пользователь: {user_info}\n"
f"- {calendar_context}\n"
f"{active_state_context}\n"
f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n"
f"1. Для любых изменений системного промпта ВСЕГДА вызывай db_prompt_node_edit(action=..., section_id=..., item_id=..., content=...). Абсолютно запрещено изменять промпт напрямую текстом.\n"
f" Пример: если просят 'добавь пункт 2.9 Тестовый пункт', ты ОБЯЗАН вызвать инструмент db_prompt_node_edit с аргументами: action='ADD', section_id=2, item_id=9, content='Тестовый пункт'.\n"
f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n"
f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n\n"
f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}"
)
f"Ты — интеллектуальный ассистент SCUD Orion AI.\n"
f"Ты управляешь задачами, аналитикой СКУД и системными настройками исключительно через инструменты (tools).\n\n"
f"СТРОГИЕ ПРАВИЛА:\n"
f"1. К оператору всегда обращайся на Вы.\n"
f"2. Для получения данных всегда вызывай соответствующий инструмент:\n"
f" - Снапшоты и срезы логов СКУД за любые даты и дни недели -> db_get_snapshots\n"
f" - Удаление снапшотов -> db_delete_snapshots\n"
f" - Задачи и бэклог (просмотр, создание, смена статуса, удаление, экспорт) -> db_get_tasks, db_tasks_edit\n"
f" - Системный промпт -> db_get_system_prompt, db_prompt_node_edit\n"
f" - Аномалии СКУД/1С -> db_get_anomalies\n"
f" - База знаний -> db_get_rules\n"
f" - Статистика БД -> db_get_stats\n"
f" - Справка -> db_get_reference\n"
f"3. Запрещено сочинять данные от себя без вызова инструментов.\n\n"
f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
f"- Пользователь: {user_info}\n"
f"- {calendar_context}\n"
f"{active_state_context}"
)
user_msg_object = {"role": "user", "content": full_user_content}
# --- [SECTION 4: OLLAMA INFERENCE & DISPATCH] --- # ANCHOR[TOOL_ROUTER]
# ANCHOR[AGENT_TOOL_DISPATCHER]
try:
if image_b64:
user_msg_object["images"] = [image_b64]
@@ -128,158 +183,330 @@ def process_chat_message(
raw_text_reply = msg.get("content", "")
tool_calls = msg.get("tool_calls", [])
tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls)
if not tool_calls:
tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls)
if tool_calls:
logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})")
messages.append(msg)
tool_names_called = []
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 = {}
for tool in tool_calls:
fn_name = tool["function"]["name"]
fn_args = tool["function"].get("arguments", {})
tool_names_called.append(fn_name)
logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
tool_result_content = ""
action_cfg = db_get_tool_action(fn_name)
logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
if fn_name == "db_get_tasks":
# Ротация контекста: закрываем старую сессию инструмента
close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}")
session_state = None
mark_last_user_message_ephemeral(session_id)
# 1. Задачи (Просмотр)
if fn_name == "db_get_tasks":
raw_tasks = db_get_tasks(user_id, status=fn_args.get("status"))
reply_text = "Вот интерактивный список ваших текущих задач:"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "TASK_INTERACTIVE_CARD",
"tasks": raw_tasks
}
# 2. Единый диспетчер задач
elif fn_name in ["db_tasks_edit", "db_add_task", "db_update_task_status", "db_delete_task"]:
action = fn_args.get("action", "UPDATE").upper()
if fn_name == "db_add_task": action = "ADD"
elif fn_name == "db_delete_task": action = "DELETE"
elif fn_name == "db_update_task_status": action = "UPDATE"
if action == "DELETE":
task_id_raw = str(fn_args.get("task_id", "")).replace("#", "").replace("TASK-", "").strip()
db_set_session_state(session_id, "TASK_DELETE_CONFIRM", {"task_id": task_id_raw, "idle_turns": 0})
reply_text = f"Вы действительно хотите удалить задачу #{task_id_raw}?"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "TASK_DELETE_CONFIRM",
"buttons": [
{"label": f"Удалить #{task_id_raw}", "value": f"подтверждаю удаление задачи {task_id_raw}", "style": "danger"},
{"label": "Отмена", "value": "отмена", "style": "secondary"}
]
}
elif action == "EXPORT":
export_res = db_export_tasks_markdown(
user_id=user_id,
filename=fn_args.get("filename"),
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 = "Вот интерактивный список ваших текущих задач:"
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
}
elif fn_name == "db_prompt_node_edit":
action = fn_args.get("action", "ADD").upper()
sec_id = int(fn_args.get("section_id", 2))
itm_id = int(fn_args.get("item_id", 1))
content = fn_args.get("content", "").strip()
# 3. Системный промпт
elif fn_name == "db_get_system_prompt":
active_prompt = db_get_active_system_prompt()
reply_text = f"Актуальный системный промпт:\n\n{active_prompt}"
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), None
with get_db_connection() as temp_conn:
temp_cursor = temp_conn.cursor()
temp_cursor.execute("SELECT section_id, item_id, content FROM system_prompt_nodes WHERE prompt_name = 'main_agent' AND is_active = 1 ORDER BY section_id, item_id")
existing_nodes = temp_cursor.fetchall()
elif fn_name == "db_prompt_node_edit":
action = str(fn_args.get("action", "ADD")).upper()
try:
sec_id = int(str(fn_args.get("section_id", 3)).strip())
except Exception:
sec_id = 3
nodes_dict = {(sec, itm): txt for sec, itm, txt in existing_nodes}
if action == "DELETE":
nodes_dict.pop((sec_id, itm_id), None)
try:
itm_id = int(str(fn_args.get("item_id", 1)).strip())
except Exception:
itm_id = 1
content = str(fn_args.get("content", "")).strip()
baseline_prompt = db_get_active_system_prompt()
with get_db_connection() as temp_conn:
temp_cursor = temp_conn.cursor()
temp_cursor.execute("""
SELECT section_id, item_id, content
FROM system_prompt_nodes
WHERE prompt_name = 'main_agent' AND is_active = 1
ORDER BY section_id, item_id
""")
existing_nodes = temp_cursor.fetchall()
nodes_dict = {(sec, itm): txt for sec, itm, txt in existing_nodes}
nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (sec_id, itm_id)} if action == "DELETE" else dict(nodes_dict)
if action != "DELETE":
nodes_dict_for_draft[(sec_id, itm_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:
nodes_dict[(sec_id, itm_id)] = content
draft_lines.append(f" {s_id}.{i_id}. {txt}")
merged_prompt = "\n".join(draft_lines)
virtual_lines = []
curr_sec = None
for (s_id, i_id), txt in sorted(nodes_dict.items()):
if i_id == 0:
if curr_sec is not None:
virtual_lines.append("")
virtual_lines.append(f"{s_id}. {txt}")
curr_sec = s_id
diff_lines = []
curr_sec = None
display_nodes = dict(nodes_dict)
if action != "DELETE":
display_nodes[(sec_id, itm_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 == sec_id and i_id == itm_id:
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>' if action == "DELETE" else 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}"
if s_id == sec_id and i_id == itm_id:
line_str = f'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-200">{s_id}.{i_id}. {txt}</span>'
virtual_lines.append(line_str)
diff_lines.append(line_str)
merged_prompt = "\n".join([re.sub(r'<[^>]+>', '', l) for l in virtual_lines])
diff_html = "\n".join(virtual_lines)
diff_html = "\n".join(diff_lines)
db_set_session_state(session_id, "PROMPT_PREVIEW", {
"draft_text": merged_prompt,
"action": action,
"section_id": sec_id,
"item_id": itm_id,
"content": content
db_set_session_state(session_id, "PROMPT_PREVIEW", {
"draft_text": merged_prompt,
"action": action,
"section_id": sec_id,
"item_id": itm_id,
"content": content,
"idle_turns": 0
})
preview_reply = f"Предпросмотр изменений системного промпта:\n\n{diff_html}\n\nДля применения подтвердите действие, отредактируйте или отмените."
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
return preview_reply, db_get_chat_history(session_id), {
"type": "PROMPT_PREVIEW",
"raw_draft": merged_prompt,
"baseline_prompt": baseline_prompt,
"buttons": [
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"},
{"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
]
}
# 4. Снапшоты СКУД
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
})
with get_db_connection() as conn_fix:
conn_fix.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_fix.commit()
preview_reply = (
f"Предпросмотр изменений системного промпта:\n\n"
f"{diff_html}\n\n"
f"Для применения подтвердите действие, отредактируйте или отмените."
)
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,
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": "Подтвердить", "value": "подтверждаю", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"},
{"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
{"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)
elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]:
tool_result_content = json.dumps({"system_prompt": db_get_active_system_prompt()}, ensure_ascii=False)
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
}
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 ([raw_id] if raw_id else [])
# Защита: итоговые Y-срезы удалять запрещено
safe_ids = [s for s in raw_ids if not str(s).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
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)
tool_result_content = json.dumps(snapshots_res, ensure_ascii=False)
# Прямое выполнение удаления в SQLite без лишних текстовых подтверждений
del_res = db_delete_snapshots(snapshot_ids=safe_ids)
# Получаем свежий реестр за ту же дату
query_date = fn_args.get("day_str", "")
updated_snapshots_res = db_get_snapshots(session_id=session_id, date_str=query_date)
elif fn_name == "db_get_current_server_time":
tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False)
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
}
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_session_states":
tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False)
elif fn_name == "db_get_rules":
tool_result_content = json.dumps(db_get_rules(), 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)
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())
tool_result_content = json.dumps(res, 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"))
tool_result_content = json.dumps(res, ensure_ascii=False)
messages.append({"role": "tool", "content": tool_result_content})
# 5. Сервисные запросы
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_rules":
tool_result_content = json.dumps(db_get_rules(), 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))
final_content = clean_raw_tool_tags(clean_output(raw_content)) or "Запрос выполнен."
is_output_ephemeral = 1 if any(name in ["db_get_system_prompt", "db_get_system_prompts", "db_prompt_node_edit"] for name in tool_names_called) else 0
for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто", "почему-то"]:
if final_content.lower().startswith(artifact):
final_content = final_content[len(artifact):].lstrip(",.!?:; -")
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral)
is_ephem = 1 if fn_name in ["db_get_snapshots", "db_get_system_prompt", "db_prompt_node_edit"] else 0
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_ephem)
return final_content, db_get_chat_history(session_id), None
# ANCHOR[AGENT_TOPIC_DRIFT]
raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "")
final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан."
for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто", "почему-то"]:
if final_reply.lower().startswith(artifact):
final_reply = final_reply[len(artifact):].lstrip(",.!?:; -")
action_payload = None
if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW"]:
idle_turns += 1
if idle_turns > 3:
db_clear_session_state(session_id)
db_purge_ephemeral_messages(session_id)
action_payload = None
session_state = None
elif idle_turns == 3:
tool_action = db_get_tool_action("db_confirm_prompt_preview")
guard_question = tool_action.get("follow_up_question", "Желаете продолжить работу с системным промптом?") if tool_action else "Желаете продолжить работу с системным промптом?"
buttons = tool_action.get("buttons", []) if tool_action else []
final_reply += f"\n\n💡 *Напоминание:* {guard_question}"
action_payload = {"type": "PROMPT_FOLLOWUP", "buttons": buttons}
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": idle_turns})
else:
db_set_session_state(session_id, session_state.get("state_type"), {"idle_turns": idle_turns})
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
return final_reply, db_get_chat_history(session_id), None
return final_reply, db_get_chat_history(session_id), action_payload
except Exception as ex:
logger.exception(f"Непредвиденная ошибка: {ex}")
logger.exception(f"Непредвиденная ошибка агента: {ex}")
error_reply = f"Внутренняя ошибка сервера: {ex}"
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
+139 -40
View File
@@ -3,67 +3,166 @@
FILE: modules/web_api/llm/core/fast_path.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Детерминированный перехват подтверждения, отмены и мгновенной очистки
эфемерного контекста без повторного вывода кнопок-вилок.
ROLE: Детерминированный мгновенный перехват нажатий кнопок подтверждения
(без задержек LLM и обращения к Ollama).
AI-CONTEXT-ANCHORS:
- ANCHOR[FAST_PATH_MAIN]: Точка входа handle_fast_path_intercept.
===============================================================================
"""
# ANCHOR[FAST_PATH_IMPORTS]
import json
import logging
from typing import Dict, Any, Tuple, Optional
from llm.db_tools import (
db_add_system_prompt,
db_clear_session_state,
db_purge_ephemeral_messages,
db_save_chat_message,
db_get_chat_history
)
# Прямые вызовы чистых доменных сервисов
from services.prompts.service import save_full_prompt_draft, apply_prompt_action, get_active_system_prompt
from services.tasks.service import get_tasks, delete_task
from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely
# Чат и сессии
from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages
from modules.web_api.llm.db.db_prompts import db_set_session_state, db_clear_session_state, db_get_tool_action
from modules.web_api.llm.core.context_manager import close_tool_session_and_cleanup
logger = logging.getLogger("FAST_PATH")
# ANCHOR[FAST_PATH_ROUTER]
# ANCHOR[FAST_PATH_MAIN]
def handle_fast_path_intercept(
session_id: str,
user_message: str,
full_user_content: str,
session_id: str,
user_message: str,
full_user_content: str,
session_state: Optional[Dict[str, Any]]
) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]:
"""
1. Подтвердить -> запись в БД + db_purge_ephemeral_messages + инфо-текст.
2. Отменить -> сброс стейта + db_purge_ephemeral_messages + инфо-текст.
Мгновенный перехват нажатий кнопок подтверждения (Fast-Path).
Возвращает (reply, history, action_type) или None, если требуется передать управление LLM.
"""
if not session_state or session_state.get("state_type") != "PROMPT_PREVIEW":
if not session_state:
return None
user_msg_clean = user_message.lower().strip(" .!?:;")
state_type = session_state.get("state_type")
state_data = session_state.get("data_json") or {}
if not isinstance(state_data, dict):
state_data = {}
draft_text = ""
if session_state.get("data_json") and isinstance(session_state["data_json"], dict):
draft_text = session_state["data_json"].get("draft_text", "")
else:
draft_text = session_state.get("pending_data", "")
msg_lower = user_message.strip().lower()
# 1. ПОДТВЕРДИТЬ ИЗМЕНЕНИЯ
if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]:
db_add_system_prompt("main_agent", draft_text)
db_clear_session_state(session_id)
deleted_count = db_purge_ephemeral_messages(session_id)
logger.info(f"Системный промпт сохранен в БД. Удалено эфемерных сообщений: {deleted_count}")
# -------------------------------------------------------------------------
# 1. ПОДТВЕРЖДЕНИЕ ПРЕВЬЮ СИСТЕМНОГО ПРОМПТА
# -------------------------------------------------------------------------
if state_type == "PROMPT_PREVIEW":
is_confirm = msg_lower in ["подтверждаю", "да", "сохраняй", "применить", "ок", "подтвердить"]
is_cancel = msg_lower in ["отмена", "отменить", "нет", "отклонить"]
reply_text = "Системный промпт успешно сохранен и применен в базе данных."
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
return reply_text, db_get_chat_history(session_id), None
if is_confirm:
action = state_data.get("action", "MANUAL_EDIT")
draft_text = state_data.get("draft_text", "")
# 2. ОТМЕНИТЬ ИЗМЕНЕНИЯ
elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]:
db_clear_session_state(session_id)
deleted_count = db_purge_ephemeral_messages(session_id)
logger.info(f"Изменения промпта отменены. Удалено эфемерных сообщений: {deleted_count}")
# Применение изменений
if action == "MANUAL_EDIT" and draft_text:
save_full_prompt_draft(draft_text, prompt_name="main_agent")
else:
sec_id = state_data.get("section_id")
itm_id = state_data.get("item_id")
content = state_data.get("content", "")
if sec_id is not None and itm_id is not None:
apply_prompt_action(action=action, section_id=sec_id, item_id=itm_id, content=content)
elif draft_text:
save_full_prompt_draft(draft_text, prompt_name="main_agent")
reply_text = "Изменения системного промпта отменены. Контекст диалога чист."
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
return reply_text, db_get_chat_history(session_id), None
# Закрытие сессии и зачистка
close_tool_session_and_cleanup(session_id, close_reason="PROMPT_APPLIED_SUCCESSFULLY")
db_clear_session_state(session_id)
tool_action = db_get_tool_action("db_confirm_prompt_preview")
reply = tool_action.get("success_template", "✅ Системный промпт успешно сохранен и применен в базе данных.") if tool_action else "✅ Системный промпт успешно сохранен и применен в базе данных."
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
return reply, db_get_chat_history(session_id), None
elif is_cancel:
close_tool_session_and_cleanup(session_id, close_reason="PROMPT_EDIT_CANCELLED")
db_clear_session_state(session_id)
tool_action = db_get_tool_action("db_cancel_prompt_preview")
reply = tool_action.get("success_template", "❌ Изменения системного промпта отменены.") if tool_action else "❌ Изменения системного промпта отменены."
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
return reply, db_get_chat_history(session_id), None
# -------------------------------------------------------------------------
# 2. ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ СНАПШОТОВ СКУД
# -------------------------------------------------------------------------
elif state_type == "SNAPSHOT_DELETE_CONFIRM":
is_confirm = "подтверждаю удаление снапшот" in msg_lower or msg_lower in ["подтверждаю", "да", "удалить"]
is_cancel = msg_lower in ["отмена", "отменить", "нет"]
if is_confirm:
snap_ids = state_data.get("snapshot_ids", [])
query_date = state_data.get("query_date", "")
# Безопасное удаление через сервис
del_res = delete_snapshots_safely(snapshot_ids=snap_ids)
close_tool_session_and_cleanup(session_id, close_reason="SNAPSHOTS_DELETED")
# Получаем свежий список за ту же дату
updated_data = get_snapshots_registry(date_str=query_date if query_date else None)
db_set_session_state(session_id, "SNAPSHOTS_VIEW", updated_data)
reply = f"✅ Успешно удалено снапшотов: {len(snap_ids)} шт."
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1)
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), {
"type": "SNAPSHOTS_CARD",
"data": updated_data
}
elif is_cancel:
close_tool_session_and_cleanup(session_id, close_reason="SNAPSHOT_DELETE_CANCELLED")
db_clear_session_state(session_id)
reply = "❌ Удаление снапшотов отменено."
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
return reply, db_get_chat_history(session_id), None
# -------------------------------------------------------------------------
# 3. ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧИ
# -------------------------------------------------------------------------
elif state_type == "TASK_DELETE_CONFIRM":
is_confirm = "подтверждаю удаление задачи" in msg_lower or msg_lower in ["подтверждаю", "да", "удалить"]
is_cancel = msg_lower in ["отмена", "отменить", "нет"]
if is_confirm:
task_id = state_data.get("task_id")
delete_task(user_id=1, task_id=str(task_id))
close_tool_session_and_cleanup(session_id, close_reason="TASK_DELETED")
db_clear_session_state(session_id)
raw_tasks = get_tasks(user_id=1)
reply = f"🗑 Задача #{task_id} удалена."
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1)
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), {
"type": "TASK_INTERACTIVE_CARD",
"tasks": raw_tasks
}
elif is_cancel:
close_tool_session_and_cleanup(session_id, close_reason="TASK_DELETE_CANCELLED")
db_clear_session_state(session_id)
reply = "❌ Удаление задачи отменено."
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
return reply, db_get_chat_history(session_id), None
return None
+3 -3
View File
@@ -21,9 +21,9 @@ VISION_MODEL = "qwen2.5vl:7b-q8_0"
LLM_OPTIONS = {
"num_predict": 8192,
"num_ctx": 8192,
"temperature": 0.1,
"repeat_penalty": 1.1,
"presence_penalty": 0.5,
"temperature": 0.0,
"repeat_penalty": 1.0,
"presence_penalty": 0.0,
"top_p": 0.9
}
-74
View File
@@ -1,74 +0,0 @@
"""
===============================================================================
FILE: modules/web_api/llm/core/prompt_merger.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Алгоритмы парсинга, предпросмотра, слияния и визуального выделения правок.
===============================================================================
"""
# ANCHOR[PROMPT_MERGER_IMPORTS]
import re
import logging
logger = logging.getLogger("PROMPT_MERGER")
# ANCHOR[PROMPT_MERGE_ENGINE]
def build_prompt_preview_merge(current_prompt: str, user_message: str, proposed_text: str) -> str:
"""
Вычисляет результирующий чистый текст промпта для записи в БД.
"""
user_msg_lower = user_message.lower()
# 1. Сценарий удаления пункта
if any(w in user_msg_lower for w in ["удали", "стереть", "убрать", "вырежи", "удалить"]):
target_num_match = re.search(r'\d+(\.\d+)*', user_message)
target_num = target_num_match.group(0) if target_num_match else ""
lines = current_prompt.splitlines()
if target_num:
new_lines = [line for line in lines if not line.strip().startswith(f"{target_num}.")]
else:
new_lines = lines
return "\n".join(new_lines)
# 2. Сценарий добавления или замены пункта
if proposed_text:
if len(proposed_text) < 500:
clean_item = proposed_text.strip()
for prefix in ["добавь пункт", "добавить пункт", "вставь пункт", "добавь"]:
if prefix in clean_item.lower():
clean_item = re.sub(prefix, "", clean_item, flags=re.IGNORECASE).strip(" .:")
lines = current_prompt.splitlines()
new_lines = []
inserted = False
for line in lines:
new_lines.append(line)
if "3.3." in line and not inserted:
item_str = clean_item if re.match(r'^\d+\.\d+\.', clean_item) else f"3.4. {clean_item}"
new_lines.append(f" {item_str}")
inserted = True
if not inserted:
new_lines.append(f" {clean_item}")
return "\n".join(new_lines)
return proposed_text or current_prompt
def highlight_prompt_diff(old_prompt: str, new_prompt: str) -> str:
"""
Генерирует текст с подсветкой добавленных/измененных строк HTML-классами Tailwind.
"""
old_lines_set = {line.strip() for line in old_prompt.splitlines() if line.strip()}
diff_lines = []
for line in new_prompt.splitlines():
if line.strip() and line.strip() not in old_lines_set:
# Выделяем новую или измененную строку красным цветом
diff_lines.append(f'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-200">{line}</span>')
else:
diff_lines.append(line)
return "\n".join(diff_lines)
+122 -37
View File
@@ -1,8 +1,17 @@
"""
===============================================================================
FILE: modules/web_api/llm/core/tool_injector.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Семантический анализ намерений оператора (Intent Classifier) и
детерминированная сборка вызовов инструментов при сбоях нативного Function Calling.
AI-CONTEXT-ANCHORS:
- ANCHOR[INTENT_INJECTOR_MAIN]: Точка входа inject_tools_if_needed.
===============================================================================
"""
import re
import json
import logging
from typing import List, Dict, Any
@@ -10,44 +19,120 @@ logger = logging.getLogger("TOOL_INJECTOR")
def clean_raw_tool_tags(text: str) -> str:
"""Удаляет сырые теги вызова инструментов и системный шум."""
if not text:
return ""
text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*(</tool_call>)?', '', text)
text = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL)
text = re.sub(r'</tool_call>\w*\[\]\(\)', '', text)
text = re.sub(r'</tool_call>', '', text)
return text.strip()
def clean_output(text: str) -> str:
if not text:
return text
# Фильтрация паразитных артефактов токенизатора Qwen
artifacts = [
"почемучка", "почемучка,", "почемучка!", "почемучка?",
"почемучто", "почто", "почему что", "почему-то",
"здравствуйте!", "привет!"
]
cleaned = text.strip()
for art in artifacts:
if cleaned.lower().startswith(art):
cleaned = cleaned[len(art):].lstrip(",.!?:; -")
cleaned = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL)
cleaned = re.sub(r'<\|.*?\|>', '', cleaned)
return cleaned.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:
return tool_calls
if '{"name":' in raw_text_content or '<tool_call>' in raw_text_content:
try:
match = re.search(r'\{"name":\s*"([^"]+)",\s*"(?:params|arguments|properties)":\s*(\{.*?\})\}', raw_text_content)
if match:
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
def clean_output(text: str) -> str:
"""Очищает маркеры форматирования."""
return text.strip() if text else ""
# ANCHOR[INTENT_INJECTOR_MAIN]
def inject_tools_if_needed(user_message: str, raw_reply: str, existing_tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Гибридный семантический анализатор:
Если модель ответила текстом с сомнениями или пропустила tool_call,
распознает доменное намерение и конструирует синтетический tool_call.
"""
if existing_tool_calls:
return existing_tool_calls
msg_clean = user_message.strip().lower()
# 1. СЕМАНТИКА: Просмотр системного промпта
# Паттерны: "покажи системный промпт", "выведи промпт", "текущий промпт", "какой сейчас системный промпт"
if "промпт" in msg_clean and any(kw in msg_clean for kw in ["покажи", "выведи", "какой", "дай", "текст", "актуальн"]):
logger.info("[IntentInjector] Распознано намерение просмотра системного промпта")
return [{
"function": {
"name": "db_get_system_prompt",
"arguments": {}
}
}]
# 2. СЕМАНТИКА: Удаление задач
# Паттерны: "удали задачу 37", "убери 37 задачу", "сотри таску #37", "сними с повестки задачу 37"
if any(kw in msg_clean for kw in ["удали", "удалить", "убери", "сотри", "сними"]) and any(kw in msg_clean for kw in ["задач", "таск"]):
task_match = re.search(r'#?\s*(\d+)', msg_clean)
if task_match:
task_id = task_match.group(1)
logger.info(f"[IntentInjector] Распознано намерение удаления задачи: #{task_id}")
return [{
"function": {
"name": "db_tasks_edit",
"arguments": {"action": "DELETE", "task_id": task_id}
}
}]
# 3. СЕМАНТИКА: Управление системным промптом (удаление и мульти-удаление)
# Паттерны: "удали 1.8 и 3.4", "удали пункт 2.3", "вычеркни 1.8, 3.4 из промпта"
if any(kw in msg_clean for kw in ["удали", "удалить", "убери", "вычеркни", "сотри"]) and not any(kw in msg_clean for kw in ["задач", "снапшот", "срез"]):
node_matches = re.findall(r'(\d+)[\.\s]+(\d+)', user_message)
if node_matches:
formatted_nodes = [f"{s}.{i}" for s, i in node_matches]
logger.info(f"[IntentInjector] Распознано намерение удаления узлов промпта: {formatted_nodes}")
return [{
"function": {
"name": "db_prompt_node_edit",
"arguments": {
"action": "BATCH_DELETE" if len(formatted_nodes) > 1 else "DELETE",
"section_id": int(node_matches[0][0]),
"item_id": int(node_matches[0][1]),
"nodes_list": formatted_nodes,
"content": ""
}
}
}]
# 4. СЕМАНТИКА: Добавление пункта промпта
# Паттерны: "добавь 3.4 Текст", "добавь пункт 3.4 Текст", "впиши в 3.4 Текст"
if any(kw in msg_clean for kw in ["добавь", "добавить", "впиши", "запиши"]) and not any(kw in msg_clean for kw in ["задач", "таск"]):
add_match = re.search(r'(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)', user_message)
if add_match:
sec_id = int(add_match.group(1))
itm_id = int(add_match.group(2))
content = add_match.group(3).strip()
logger.info(f"[IntentInjector] Распознано намерение добавления узла промпта: {sec_id}.{itm_id}")
return [{
"function": {
"name": "db_prompt_node_edit",
"arguments": {
"action": "ADD",
"section_id": sec_id,
"item_id": itm_id,
"content": content
}
}
}]
# 5. СЕМАНТИКА: Смена статуса задач
if any(kw in msg_clean for kw in ["в работу", "начни", "стартуй", "за работу"]):
task_match = re.search(r'#?\s*(\d+)', msg_clean)
if task_match:
task_id = task_match.group(1)
logger.info(f"[IntentInjector] Распознано намерение взятия в работу задачи: #{task_id}")
return [{
"function": {
"name": "db_tasks_edit",
"arguments": {"action": "UPDATE", "task_id": task_id, "status": "IN_PROGRESS"}
}
}]
if any(kw in msg_clean for kw in ["заверши", "закрой", "готово", "выполнено"]):
task_match = re.search(r'#?\s*(\d+)', msg_clean)
if task_match:
task_id = task_match.group(1)
logger.info(f"[IntentInjector] Распознано намерение закрытия задачи: #{task_id}")
return [{
"function": {
"name": "db_tasks_edit",
"arguments": {"action": "UPDATE", "task_id": task_id, "status": "COMPLETED"}
}
}]
return existing_tool_calls
+21 -22
View File
@@ -7,6 +7,7 @@ ROLE: Реляционное управление системным промп
базой знаний, реестром действий и сессионными стейтами.
===============================================================================
"""
import re
import json
import logging
from typing import List, Dict, Any, Optional
@@ -239,7 +240,7 @@ def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]:
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:
@@ -249,30 +250,28 @@ def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
current_sec = 1
current_itm = 0
for line in prompt_text.splitlines():
stripped = line.strip()
if not stripped:
for raw_line in prompt_text.splitlines():
clean_line = re.sub(r'<[^>]+>', '', raw_line).strip()
if not clean_line:
continue
# Проверяем заголовок раздела (например, "1. РОЛЬ И ЗАДАЧИ...")
parts = stripped.split(".", 1)
if len(parts) == 2 and parts[0].strip().isdigit():
sec_num = int(parts[0].strip())
rest = parts[1].strip()
# Проверяем, это подпункт (например, "1.1. Текст") или заголовок раздела
sub_parts = rest.split(".", 1)
if len(sub_parts) == 2 and sub_parts[0].strip().isdigit():
current_sec = sec_num
current_itm = int(sub_parts[0].strip())
content = sub_parts[1].strip()
else:
current_sec = sec_num
current_itm = 0
content = rest
# 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 = stripped
content = clean_line
cursor.execute("""
INSERT OR REPLACE INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active)
-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} удалена"}
+83 -18
View File
@@ -1,17 +1,53 @@
"""
===============================================================================
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
from .db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages
from .db.db_tasks import normalize_task_id, db_get_tasks, db_add_task, db_update_task_status, db_delete_task
from .db.db_snapshots import db_get_snapshots, db_delete_snapshots
# ANCHOR[FACADE_EXPORTS]
from typing import Optional, List, Dict, Any
from core.connection import DB_PATH, get_connection as get_db_connection
# Домен: Задачи
from services.tasks.service import (
get_tasks as db_get_tasks,
add_task as db_add_task,
update_task_details as db_update_task_details,
delete_task as db_delete_task,
execute_task_action as db_tasks_edit
)
from services.tasks.exporter import export_tasks_to_markdown as db_export_tasks_markdown
from services.tasks.repository import normalize_task_id
# Домен: Системный промпт
from services.prompts.service import (
get_active_system_prompt as db_get_active_system_prompt,
apply_prompt_action as db_apply_prompt_node_action,
save_full_prompt_draft,
create_prompt_preview
)
# Домен: База знаний
from services.knowledge.service import (
get_rules as db_get_rules,
add_rule as db_add_rule
)
# Чат, сессии, статистика
from .db.db_chat import (
db_save_chat_message,
db_get_chat_history,
db_purge_ephemeral_messages,
db_clear_chat_history
)
from .db.db_prompts import (
db_get_active_system_prompt,
db_add_system_prompt,
db_apply_prompt_node_action,
db_get_tool_action,
db_get_rules,
db_set_session_state,
db_get_session_state,
db_clear_session_state,
@@ -19,13 +55,42 @@ from .db.db_prompts import (
db_get_anomalies,
db_get_reference
)
from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time
def db_get_current_server_time():
now = datetime.now()
days_ru = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
return {
"current_date": now.strftime("%d.%m.%Y"),
"current_time": now.strftime("%H:%M:%S"),
"day_of_week": days_ru[now.weekday()],
"iso_date": now.strftime("%Y-%m-%d")
}
def db_add_system_prompt(name_or_text: str, draft_text: str = None) -> None:
"""Совместимая обертка для сохранения системного промпта."""
if draft_text is not None:
save_full_prompt_draft(draft_text, prompt_name=name_or_text)
else:
save_full_prompt_draft(name_or_text, prompt_name="main_agent")
def db_get_snapshots(session_id: str = "web_session_main", date_str: str = None, original_user_message: str = "") -> Dict[str, Any]:
"""Совместимый фасад выборки снапшотов с сохранением стейта сессии."""
from services.snapshots.service import get_snapshots_registry
from .core.calendar_utils import parse_relative_date_ru
clean_date = (date_str or "").strip()
if not clean_date and original_user_message:
clean_date = parse_relative_date_ru(original_user_message)
res = get_snapshots_registry(date_str=clean_date if clean_date else None)
db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=res)
return res
def db_delete_snapshots(snapshot_id: str = None, snapshot_ids: List[str] = None, day_str: str = None) -> Dict[str, Any]:
"""Совместимый фасад безопасного удаления снапшотов."""
from services.snapshots.service import delete_snapshots_safely
target_ids = []
if snapshot_ids:
target_ids.extend(snapshot_ids)
if snapshot_id:
if isinstance(snapshot_id, str) and "," in snapshot_id:
target_ids.extend([s.strip() for s in snapshot_id.split(",")])
else:
target_ids.append(snapshot_id)
return delete_snapshots_safely(snapshot_ids=target_ids)
+151 -112
View File
@@ -1,15 +1,82 @@
"""
===============================================================================
FILE: modules/web_api/llm/schemas.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm
ROLE: Декларативная схема нативных инструментов (Function Calling) для Ollama.
AI-CONTEXT-ANCHORS:
- ANCHOR[SCHEMA_PROMPT_EDIT]: Схема управления узлами системного промпта.
- ANCHOR[SCHEMA_TASKS_EDIT]: Консолидированная схема управления задачами.
- ANCHOR[SCHEMA_SNAPSHOTS]: Схема доступа к логам и срезам СКУД.
===============================================================================
"""
# ANCHOR[SCHEMA_PROMPT_EDIT]
TOOLS_SCHEMA = [
{
"type": "function",
"function": {
"name": "db_prompt_node_edit",
"description": (
"Управление элементами системного промпта (добавление, изменение, удаление пунктов).\n"
"Поддерживает как одиночные пункты (section_id, item_id), так и список пунктов для удаления (nodes_list=['1.8', '3.4']).\n"
"При любом запросе на удаление или добавление пунктов системного промпта ТЫ ОБЯЗАН вызвать этот инструмент."
),
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["ADD", "EDIT", "DELETE", "BATCH_DELETE"],
"description": "Тип действия"
},
"section_id": {
"type": "integer",
"description": "Номер раздела (например 1)"
},
"item_id": {
"type": "integer",
"description": "Номер пункта (например 8)"
},
"nodes_list": {
"type": "array",
"items": {"type": "string"},
"description": "Список пунктов для удаления/изменения, например ['1.8', '3.4']"
},
"content": {
"type": "string",
"description": "Текст пункта"
}
},
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "db_get_system_prompt",
"description": "Просмотр текущего активного системного промпта ассистента из базы данных.",
"parameters": {
"type": "object",
"properties": {}
}
}
},
# ANCHOR[SCHEMA_TASKS_EDIT]
{
"type": "function",
"function": {
"name": "db_get_tasks",
"description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ. Вызывай СРАЗУ при запросе 'покажи мои задачи' или 'список задач'. ВАЖНОЕ ПРАВИЛО ВЫВОДА: Выводи задачи ЕДИНЫМ плоским списком (нумерованным или маркированным) по порядку ID. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО группировать задачи по статусам (В процессе, Бэклог, Завершены) или создавать подзаголовки, если оператор явно не попросил о группировке!",
"description": "Просмотр реестра задач и бэклога текущего пользователя.",
"parameters": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Фильтр статуса: BACKLOG, IN_PROGRESS или COMPLETED."
"enum": ["ALL", "IN_PROGRESS", "PLANNED", "COMPLETED"],
"description": "Опциональный фильтр статуса задач"
}
}
}
@@ -18,64 +85,70 @@ TOOLS_SCHEMA = [
{
"type": "function",
"function": {
"name": "db_get_rules",
"description": "ПОЛУЧИТЬ БАЗУ ЗНАНИЙ ИИ И ПРАВИЛА АРБИТРАЖА (ai_knowledge_base). Вызывай когда пользователь просит показать базу знаний, правила, инструкции или промпты.",
"parameters": {"type": "object", "properties": {}}
}
},
{
"type": "function",
"function": {
"name": "db_get_system_prompt",
"description": "ВЫЗЫВАЙ ВСЕГДА при наличии в сообщении фраз: 'покажи системный промпт', 'покажи промпт', 'выведи промпт', 'системный промпт'. Запрещено отвечать текстом без вызова этого инструмента.",
"parameters": {
"type": "object",
"properties": {},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "db_update_task_status",
"description": "Изменить статус и/или срок выполнения задачи в реестре.",
"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": {
"task_id": {"type": "string", "description": "Идентификатор задачи, например TASK-17"},
"status": {"type": "string", "description": "Новый статус: COMPLETED, IN_PROGRESS или BACKLOG"},
"due_date": {"type": "string", "description": "Срок выполнения задачи"}
"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": ["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"]
"required": ["action"]
}
}
},
# ANCHOR[SCHEMA_SNAPSHOTS]
{
"type": "function",
"function": {
"name": "db_get_snapshots",
"description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СПИСОК СНАПШОТОВ ИЗ БАЗЫ SQLITE. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, даже если список снапшотов уже есть в истории чата или пользователь просит 'обновить', 'повторить запрос', 'проверить снова'. ЗАПРЕЩЕНО беречь контекст и выводить старые данные из истории!",
"description": "Получение списка снапшотов и срезов логов СКУД из базы данных за конкретную дату.",
"parameters": {
"type": "object",
"properties": {
"date_str": {
"type": "string",
"description": "Точная дата в формате ДД.ММ.ГГГГ (например, '12.08.2026'), взятая из [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА]."
"description": "Дата в формате ДД.ММ.ГГГГ или относительное слово"
}
}
}
@@ -85,12 +158,18 @@ TOOLS_SCHEMA = [
"type": "function",
"function": {
"name": "db_delete_snapshots",
"description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.",
"description": "Удаление снапшотов СКУД по идентификатору или дате.",
"parameters": {
"type": "object",
"properties": {
"snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"},
"day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"}
"snapshot_id": {
"type": "string",
"description": "Идентификатор конкретного снапшота"
},
"day_str": {
"type": "string",
"description": "Дата всех снапшотов за день"
}
}
}
}
@@ -98,29 +177,31 @@ TOOLS_SCHEMA = [
{
"type": "function",
"function": {
"name": "db_get_current_server_time",
"description": "ПОЛУЧИТЬ ТЕКУЩУЮ ДАТУ, ВРЕМЯ И ДЕНЬ НЕДЕЛИ СЕРВЕРА. Вызывай МГНОВЕННО при любых вопросах пользователя про точное текущее время или текущую дату.",
"name": "db_get_anomalies",
"description": "Просмотр истории аномалий и расхождений между СКУД и 1С.",
"parameters": {
"type": "object",
"properties": {},
"required": []
"properties": {
"date_str": {
"type": "string",
"description": "Опциональная дата в формате ДД.ММ.ГГГГ"
},
"limit": {
"type": "integer",
"description": "Лимит записей"
}
}
}
}
},
{
"type": "function",
"function": {
"name": "db_add_task",
"description": "Добавить новую задачу в бэклог проекта.",
"name": "db_get_rules",
"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"]
"properties": {}
}
}
},
@@ -128,80 +209,38 @@ TOOLS_SCHEMA = [
"type": "function",
"function": {
"name": "db_get_stats",
"description": "ПОЛУЧИТЬ ОБЩУЮ СТАТИСТИКУ БАЗЫ ДАННЫХ. Вызывай, когда пользователь просит показать общую статистику БД, количество записей в таблицах или размер базы.",
"parameters": {"type": "object", "properties": {}}
}
},
{
"type": "function",
"function": {
"name": "db_get_anomalies",
"description": "ПОЛУЧИТЬ ИСТОРИЮ АНОМАЛИЙ СКУД ⟷ 1С. Вызывай при запросах на просмотр аномалий или расхождений. Передавай date_str если пользователь просит аномалии за конкретный день, или увеличенный limit (например 100) если просит все.",
"description": "Получение статистики количества записей в таблицах базы данных.",
"parameters": {
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "Максимальное количество записей (по умолчанию 100)"},
"date_str": {"type": "string", "description": "Опциональная дата в формате ДД.ММ.ГГГГ"}
}
"properties": {}
}
}
},
{
"type": "function",
"function": {
"name": "db_get_session_states",
"description": "ПОЛУЧИТЬ АКТИВНЫЕ СЕССИИ И ПРЕВЬЮ (session_states). Вызывай, когда пользователь просит показать текущие сессии или статус превью.",
"parameters": {"type": "object", "properties": {}}
"name": "db_get_current_server_time",
"description": "Получение текущего точного времени сервера.",
"parameters": {
"type": "object",
"properties": {}
}
}
},
{
"type": "function",
"function": {
"name": "db_get_reference",
"description": "ПОЛУЧИТЬ СИСТЕМНЫЙ СПРАВОЧНИК И ПРИМЕРЫ КОМАНД ДЛЯ ОПЕРАТОРА (system_reference). Вызывай ВСЕГДА, когда пользователь спрашивает про возможности ассистента, список команд, примерах промптов или справе по работе с системой.",
"description": "Справка о возможностях ассистента и примеры доступных команд.",
"parameters": {
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "Фильтр категории: scud, tasks, calendar или system. Если просят всё — не передавай параметр."
"description": "Категория справки"
}
}
}
}
},
{
"type": "function",
"function": {
"name": "db_prompt_node_edit",
"description": (
"Сформировать предпросмотр изменения системного промпта через реляционные узлы. "
"Вызывай при любых запросах на добавление, изменение или удаление пунктов. "
"Раздел и пункт передавай числами (например, для пункта '2.9' -> section_id=2, item_id=9)."
),
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["ADD", "UPDATE", "DELETE"],
"description": "Действие: ADD (добавить пункт), UPDATE (изменить пункт), DELETE (удалить пункт)"
},
"section_id": {
"type": "integer",
"description": "Номер раздела (например, 2)"
},
"item_id": {
"type": "integer",
"description": "Номер пункта внутри раздела (например, 9)"
},
"content": {
"type": "string",
"description": "Текст формулировки пункта (для ADD и UPDATE). Без префикса номера!"
}
},
"required": ["action", "section_id", "item_id"]
}
}
}
]
+8 -1
View File
@@ -16,6 +16,12 @@ CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
if CURRENT_DIR not in sys.path:
sys.path.insert(0, CURRENT_DIR)
ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, "../../"))
for p in [ROOT_DIR, CURRENT_DIR]:
if p not in sys.path:
sys.path.insert(0, p)
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
@@ -25,6 +31,7 @@ from routers.auth import router as auth_router
from routers.admin import router as admin_router
from routers.tasks import router as tasks_router
from routers.chat import router as chat_router
from routers.files import router as files_router
# ANCHOR[APP_CONFIG]
logging.basicConfig(
@@ -53,6 +60,7 @@ app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(tasks_router)
app.include_router(chat_router)
app.include_router(files_router)
# ANCHOR[ROOT_STATIC_ROUTES]
@app.get("/")
@@ -81,7 +89,6 @@ def serve_static_fallback(file_path: str):
return FileResponse(target, media_type="text/css")
return FileResponse(target)
# Резервный рекурсивный поиск файла в static
filename = os.path.basename(clean_path)
for root, _, files in os.walk(STATIC_DIR):
if filename in files:
+9 -14
View File
@@ -5,8 +5,6 @@ ROLE: Аутентификация, валидация JWT-токенов и у
===============================================================================
"""
# ANCHOR[AUTH_ROUTER_IMPORTS]
import sqlite3
import logging
from datetime import datetime, timedelta
from typing import Dict, Any, Optional
@@ -17,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from llm.db_tools import DB_PATH
from core.connection import get_connection
JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
ALGORITHM = "HS256"
@@ -27,11 +25,10 @@ security = HTTPBearer()
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
# ANCHOR[AUTH_DB_HELPERS]
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
return get_connection(row_factory=True)
def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
payload = {
@@ -42,6 +39,7 @@ def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
}
return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
try:
token = credentials.credentials
@@ -58,21 +56,20 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(securit
headers={"WWW-Authenticate": "Bearer"},
)
# ANCHOR[AUTH_SCHEMAS]
class AuthRequest(BaseModel):
username: str
password: str
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
# ANCHOR[AUTH_ENDPOINTS]
@router.post("/login")
def login(req: AuthRequest):
username = req.username.strip().lower()
logging.info(f"===> Попытка входа для пользователя: {username}")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,))
@@ -80,15 +77,14 @@ def login(req: AuthRequest):
conn.close()
if not user or not pwd_context.verify(req.password, user["password_hash"]):
logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}")
raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль")
is_admin = bool(user["is_admin"]) or (user["username"] == "puh")
token = create_access_token(user["id"], user["username"], is_admin)
logging.info(f"===> УСПЕХ: Авторизован пользователь {username}")
return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin}
@router.post("/change-password")
def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
if not req.new_password or len(req.new_password) < 4:
@@ -108,5 +104,4 @@ def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = D
conn.commit()
conn.close()
logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}")
return {"status": "success", "message": "Пароль успешно изменен"}
+9 -2
View File
@@ -13,7 +13,7 @@ from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
from pydantic import BaseModel
from .auth import get_current_user
from llm.agent import process_chat_message
from modules.ai_engine.agent import process_chat_message
from llm.file_parser import extract_text_from_file
from llm.db_tools import db_set_session_state, db_get_session_state
@@ -82,5 +82,12 @@ def update_draft_endpoint(
if not state or state.get("state_type") != "PROMPT_PREVIEW":
raise HTTPException(status_code=400, detail="Нет активного превью для редактирования")
db_set_session_state(req.session_id, "PROMPT_PREVIEW", {"draft_text": req.draft_text.strip()})
# Сохраняем чистый текст с пометкой MANUAL_EDIT
db_set_session_state(req.session_id, "PROMPT_PREVIEW", {
"draft_text": req.draft_text.strip(),
"action": "MANUAL_EDIT",
"section_id": None,
"item_id": None,
"content": ""
})
return {"status": "success", "message": "Черновик успешно обновлен в сессии"}
+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}"
}
)
+48 -43
View File
@@ -1,71 +1,76 @@
"""
===============================================================================
FILE: modules/web_api/routers/tasks.py
ROLE: REST API управления задачами (GET / POST / PATCH / DELETE).
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / routers
ROLE: REST API эндпоинты реестра задач.
===============================================================================
"""
# ANCHOR[TASKS_ROUTER_IMPORTS]
from typing import Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional, Dict, Any
from .auth import get_current_user
from llm.db_tools import (
db_get_tasks,
db_add_task,
db_update_task_status,
db_delete_task
)
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"])
router = APIRouter(prefix="/api/v1/tasks", tags=["Tasks"])
# ANCHOR[TASKS_SCHEMAS]
class CreateTaskRequest(BaseModel):
class TaskCreateRequest(BaseModel):
title: str
priority: Optional[str] = "MEDIUM"
module: Optional[str] = "general"
due_date: Optional[str] = None
status: Optional[str] = "BACKLOG"
class UpdateTaskRequest(BaseModel):
status: Optional[str] = "COMPLETED"
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
# ANCHOR[TASKS_ENDPOINTS]
@router.get("")
def get_tasks(user: Dict[str, Any] = Depends(get_current_user)):
"""Получить список всех задач текущего авторизованного пользователя."""
return db_get_tasks(user_id=user["id"])
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("")
def create_task_endpoint(req: CreateTaskRequest, user: Dict[str, Any] = Depends(get_current_user)):
"""Прямое создание задачи."""
res = db_add_task(
user_id=user["id"],
module=req.module or "general",
title=req.title.strip(),
priority=req.priority or "MEDIUM",
due_date=req.due_date
)
return res
@router.patch("/{task_id}")
def update_task_endpoint(task_id: str, req: UpdateTaskRequest, user: Dict[str, Any] = Depends(get_current_user)):
"""Прямое обновление статуса и срока задачи."""
res = db_update_task_status(
user_id=user["id"],
task_id=task_id,
status=req.status or "COMPLETED",
due_date=req.due_date
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=404, detail=res["error"])
raise HTTPException(status_code=400, detail=res["error"])
return res
@router.delete("/{task_id}")
def delete_task_endpoint(task_id: str, user: Dict[str, Any] = Depends(get_current_user)):
"""Прямое удаление задачи."""
res = db_delete_task(user_id=user["id"], task_id=task_id)
@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)")
+146 -245
View File
@@ -2,263 +2,164 @@
<html lang="ru">
<head>
<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">
<title>SCUD Orion AI — Context Manager</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SCUD Orion AI — Управление и Аналитика</title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- FontAwesome Icons -->
<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="stylesheet" href="/css/styles.css">
</head>
<body class="bg-slate-100 text-slate-800 h-[100dvh] w-full flex flex-col font-sans overflow-hidden">
<body class="bg-slate-100 font-sans h-screen flex overflow-hidden text-slate-800">
<!-- ANCHOR[AUTH_MODAL]: Модальное окно входа -->
<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">
<div class="flex items-center space-x-3 mb-6">
<div class="bg-indigo-600 text-white p-3 rounded-xl">
<i class="fa-solid fa-user-shield text-xl"></i>
</div>
<div>
<h2 class="text-lg font-bold text-slate-900">SCUD Orion AI</h2>
<p class="text-xs text-slate-500">Авторизация в системе</p>
</div>
</div>
<div class="space-y-4">
<div>
<label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">Имя пользователя</label>
<input type="text" id="auth-username-input" placeholder="Введите логин..." required autocomplete="username"
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>
<label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">Пароль</label>
<input type="password" id="auth-password-input" placeholder="Введите пароль..." required autocomplete="current-password"
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="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>
<!-- ANCHOR[CHANGE_PWD_MODAL]: Модальное окно смены пароля -->
<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>
<!-- ANCHOR[ADMIN_MODAL]: Модальное окно управления пользователями -->
<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>
</div>
<div id="admin-msg" class="hidden text-[11px] text-red-600 pt-1"></div>
</div>
<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>
</div>
</div>
</div>
<!-- ANCHOR[HEADER_BAR]: Хедер интерфейса -->
<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 class="flex items-center space-x-2.5">
<div class="bg-indigo-600 text-white p-2 rounded-xl shrink-0">
<i class="fa-solid fa-brain text-lg"></i>
</div>
<div>
<h1 class="text-sm font-bold text-slate-900 leading-tight">SCUD Orion AI</h1>
<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>
<!-- ANCHOR[CHAT_MAIN_CONTAINER]: Главный контейнер чата -->
<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">
<!-- Боковая панель (Задачи и Навигация) -->
<aside id="task-drawer" class="w-80 sm:w-96 bg-white border-r border-slate-200 flex flex-col shrink-0 h-full z-20 shadow-sm transition-all duration-300">
<!-- Шапка панели задач -->
<div class="p-4 border-b border-slate-200 flex items-center justify-between bg-slate-50/70">
<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 class="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center text-white shadow-sm">
<i class="fa-solid fa-list-check text-sm"></i>
</div>
<div>
<h2 class="font-bold text-sm text-slate-900 leading-tight">Бэклог задач</h2>
<p class="text-[11px] text-slate-500">SCUD Orion AI Roadmap</p>
</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">
<span>Отправить</span>
<i class="fa-solid fa-paper-plane text-xs"></i>
</button>
</div>
<button type="button" onclick="openAddTaskModal()" title="Добавить задачу"
class="px-2.5 py-1.5 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 border border-indigo-200 rounded-lg text-xs font-semibold flex items-center gap-1 transition cursor-pointer">
<i class="fa-solid fa-plus"></i>
<span>Задача</span>
</button>
</div>
</div>
<!-- ANCHOR[TASK_DRAWER_CONTAINER]: Боковая панель задач -->
<div id="drawer-backdrop" onclick="toggleDrawer()" class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm hidden transition-opacity z-40"></div>
<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="p-3.5 border-b border-slate-200 flex justify-between items-center bg-slate-50 shrink-0">
<h2 class="font-bold text-slate-800 flex items-center gap-2 text-sm">
<i class="fa-solid fa-list-check text-indigo-600"></i> Мой реестр задач
</h2>
<div class="flex items-center gap-3">
<button type="button" onclick="loadTasks()" class="text-xs text-slate-500 hover:text-indigo-600 transition p-1" title="Обновить">
<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>
<!-- Фильтры задач -->
<div class="px-4 py-2.5 border-b border-slate-100 flex items-center justify-between gap-1 text-xs">
<button onclick="filterTasksByTab('IN_PROGRESS')" id="tab-in-progress" class="task-tab-btn font-semibold px-2.5 py-1 rounded-md text-indigo-600 bg-indigo-50 transition">В работе</button>
<button onclick="filterTasksByTab('BACKLOG')" id="tab-backlog" class="task-tab-btn font-medium px-2.5 py-1 rounded-md text-slate-600 hover:bg-slate-100 transition">В планах</button>
<button onclick="filterTasksByTab('COMPLETED')" id="tab-completed" class="task-tab-btn font-medium px-2.5 py-1 rounded-md text-slate-600 hover:bg-slate-100 transition">Готово</button>
<button onclick="filterTasksByTab('ALL')" id="tab-all" class="task-tab-btn font-medium px-2.5 py-1 rounded-md text-slate-600 hover:bg-slate-100 transition">Все</button>
</div>
<!-- Список задач с прокруткой (ID исправлен на tasks-list) -->
<div id="tasks-list" class="flex-1 overflow-y-auto p-3 space-y-2.5">
<div class="text-center py-8 text-xs text-slate-400">Загрузка задач...</div>
</div>
<!-- Подвал панели пользователя -->
<div class="p-3 border-t border-slate-200 bg-slate-50/50 flex items-center justify-between text-xs">
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-full bg-slate-300 flex items-center justify-center text-slate-700 font-bold">
<i class="fa-solid fa-user text-xs"></i>
</div>
<div class="truncate">
<span id="current-username" class="font-semibold text-slate-900 block truncate">Александр Пушков</span>
<span id="user-role-badge" class="text-[10px] text-indigo-600 font-medium">Администратор</span>
</div>
</div>
</div>
<!-- ANCHOR[DRAWER_TAB_RENAMING]: Вкладки фильтрации боковой панели -->
<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">
<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>
<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>
<button onclick="logout()" title="Выйти" class="p-1.5 text-slate-400 hover:text-rose-600 transition">
<i class="fa-solid fa-arrow-right-from-bracket"></i>
</button>
</div>
</aside>
<!-- Подключение скриптов интерфейса -->
<script src="/static/js/auth.js"></script>
<script src="/static/js/tasks.js"></script>
<script src="/static/js/chat/task_widget.js"></script>
<script src="/static/js/chat/core.js"></script>
<script src="/static/js/app.js"></script>
<!-- Основная рабочая область чата -->
<main class="flex-1 flex flex-col min-w-0 bg-white relative h-full overflow-hidden">
<!-- Верхний заголовок чата -->
<header class="h-14 border-b border-slate-200 px-4 flex items-center justify-between bg-white shrink-0 z-10">
<div class="flex items-center gap-3">
<button type="button" onclick="toggleTaskDrawer()" class="p-1.5 text-slate-500 hover:text-indigo-600 hover:bg-slate-100 rounded-lg transition" title="Переключить боковую панель">
<i class="fa-solid fa-bars"></i>
</button>
<div>
<h1 class="font-bold text-sm text-slate-900 flex items-center gap-2">
<span>SCUD Orion AI Assistant</span>
<span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-100 text-emerald-800">Online</span>
</h1>
<p class="text-[11px] text-slate-500">Система интеллектуального аудита и контроля СКУД / 1С</p>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<button type="button" onclick="handleActionButtonClick('покажи системный промпт')" class="px-2.5 py-1 text-slate-600 hover:text-indigo-600 hover:bg-slate-100 rounded-md border border-slate-200 transition">
<i class="fa-solid fa-terminal mr-1"></i> Промпт
</button>
<button type="button" onclick="handleActionButtonClick('покажи снапшоты')" class="px-2.5 py-1 text-slate-600 hover:text-indigo-600 hover:bg-slate-100 rounded-md border border-slate-200 transition">
<i class="fa-solid fa-camera mr-1"></i> Срезы СКУД
</button>
</div>
</header>
<!-- Drop Overlay при перетаскивании файлов -->
<div id="drop-overlay" class="hidden absolute inset-0 bg-indigo-600/10 backdrop-blur-[2px] border-2 border-dashed border-indigo-500 rounded-2xl m-4 z-50 items-center justify-center flex-col gap-2 pointer-events-none">
<i class="fa-solid fa-cloud-arrow-up text-3xl text-indigo-600 animate-bounce"></i>
<p class="text-xs font-bold text-indigo-900">Перетащите файл сюда для отправки в диалог</p>
</div>
<!-- Окно сообщений с центрированием контента -->
<div id="chat-window" class="flex-1 p-4 overflow-y-auto bg-slate-50/50 scroll-smooth">
<!-- Центрирующая колонка для сообщений -->
<div class="max-w-4xl w-full mx-auto space-y-4">
<!-- Стартовое приветственное сообщение -->
<div class="chat-message-card bg-white border border-slate-200 rounded-2xl p-4 shadow-sm w-full">
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
<i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент SCUD Orion AI
</p>
<div class="text-slate-800 text-xs sm:text-sm leading-relaxed">
Привет! У каждого пользователя свое изолированное пространство задач. Вы можете задавать вопросы нейросети, прикреплять файлы или ставить персональные задачи.
</div>
</div>
<!-- Невидимая распорка в самом низу для свободного скролла любого вопроса наверх -->
<div id="chat-bottom-spacer" class="min-h-[85vh] pointer-events-none w-full"></div>
</div>
</div>
<!-- Центрированная нижняя панель ввода -->
<div class="p-3 bg-white border-t border-slate-200 shrink-0 z-10">
<div class="max-w-4xl w-full mx-auto">
<!-- Блок предпросмотра прикрепленного файла -->
<div id="file-preview-container" class="hidden mb-2 p-2 bg-indigo-50 border border-indigo-200 rounded-xl flex items-center justify-between">
<div class="flex items-center gap-2 min-w-0">
<i class="fa-solid fa-file-arrow-up text-indigo-600 text-sm"></i>
<span id="file-name-display" class="text-xs font-semibold text-slate-800 truncate"></span>
<span id="file-size-display" class="text-[10px] text-slate-500"></span>
</div>
<button type="button" onclick="clearAttachedFile()" class="text-slate-400 hover:text-rose-600 transition p-1">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<!-- Форма отправки -->
<form id="chat-form" onsubmit="sendMessage(event)" class="flex items-end gap-2">
<input type="file" id="file-input" class="hidden" onchange="handleFileSelect(event)" />
<button type="button" onclick="document.getElementById('file-input').click()"
title="Прикрепить файл"
class="p-2.5 text-slate-500 hover:text-indigo-600 hover:bg-slate-100 rounded-xl transition shrink-0 cursor-pointer">
<i class="fa-solid fa-paperclip text-sm"></i>
</button>
<div class="flex-1 bg-slate-100 border border-slate-300 focus-within:border-indigo-600 focus-within:bg-white rounded-2xl p-1.5 transition flex items-center shadow-inner">
<textarea id="user-input" rows="1"
placeholder="Команда, вопрос или перетащите файл сюда..."
class="w-full bg-transparent px-2 text-xs sm:text-sm text-slate-800 focus:outline-none resize-none overflow-y-auto leading-relaxed"
style="height: 24px; max-height: 120px;"></textarea>
</div>
<button type="submit" id="send-btn"
class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white p-2.5 rounded-xl transition shrink-0 shadow-sm cursor-pointer">
<i class="fa-solid fa-paper-plane text-sm"></i>
</button>
</form>
</div>
</div>
</main>
<!-- Скрипты клиентской логики -->
<script src="/js/auth.js"></script>
<script src="/js/tasks.js"></script>
<script src="/js/chat/task_widget.js"></script>
<script src="/js/chat/core.js"></script>
<script src="/js/app.js"></script>
</body>
</html>
-568
View File
@@ -1,568 +0,0 @@
/**
===============================================================================
FILE: modules/web_api/static/js/chat.js
ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, генерация динамических
кнопок подтверждений и блокировка ввода при активном действии.
===============================================================================
*/
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 setInputLocked(isLocked) {
const input = document.getElementById("user-input");
const sendBtn = document.getElementById("send-btn");
if (input) {
input.disabled = isLocked;
if (isLocked) {
input.placeholder = "Выберите действие с помощью кнопок выше...";
input.classList.add("cursor-not-allowed", "opacity-60");
} else {
input.placeholder = "Команда, вопрос или перетащите файл сюда...";
input.classList.remove("cursor-not-allowed", "opacity-60");
}
}
if (sendBtn) {
sendBtn.disabled = isLocked;
if (isLocked) {
sendBtn.classList.add("opacity-40", "cursor-not-allowed");
} else {
sendBtn.classList.remove("opacity-40", "cursor-not-allowed");
}
}
}
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();
setInputLocked(false);
const input = document.getElementById("user-input");
if (input) {
input.value = text;
sendMessage();
}
}
// --- [INTERACTIVE TASK WIDGET] ---
let activeWidgetTasksMap = new Map();
function renderTaskItemsHtml(tasks, filterStatus) {
const filtered = tasks.filter(t => {
if (filterStatus === "ALL") return true;
return t.status === filterStatus;
});
if (filtered.length === 0) {
const label = filterStatus === 'BACKLOG' ? 'В планах' : filterStatus === 'IN_PROGRESS' ? 'В работе' : filterStatus === 'COMPLETED' ? 'Завершенные' : 'Все';
return `<div class="text-slate-400 text-xs py-4 text-center">Нет задач со статусом «${label}»</div>`;
}
return filtered.map(t => {
const isDone = t.status === "COMPLETED";
const isProgress = t.status === "IN_PROGRESS";
const checkedAttr = isDone ? "checked" : "";
const textClass = isDone ? "line-through text-slate-400" : "text-slate-800 font-medium";
let statusPill = `<span class="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded border border-slate-200">В планах</span>`;
if (isDone) {
statusPill = `<span class="text-[10px] bg-emerald-50 text-emerald-700 px-2 py-0.5 rounded border border-emerald-300 font-semibold">Завершено</span>`;
} else if (isProgress) {
statusPill = `<span class="text-[10px] bg-amber-50 text-amber-700 px-2 py-0.5 rounded border border-amber-300 font-semibold">В работе</span>`;
}
let prioPill = "";
if (t.priority === "HIGH") {
prioPill = `<span class="text-[9px] bg-red-50 text-red-700 font-bold px-1.5 py-0.5 rounded border border-red-200 uppercase">HIGH</span>`;
}
return `
<div class="flex items-center justify-between p-2.5 bg-slate-50 hover:bg-slate-100/80 rounded-xl border border-slate-200 transition gap-2" id="widget-task-${t.task_id}">
<div class="flex items-center gap-2.5 flex-1 min-w-0">
<input type="checkbox" ${checkedAttr} onchange="toggleTaskStatusInline('${t.task_id}', this.checked)"
class="w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500 cursor-pointer">
<div class="flex flex-col min-w-0 flex-1">
<div class="flex items-center gap-1.5 mb-0.5">
<span class="font-mono text-[11px] font-bold text-slate-700">${t.task_id}</span>
${prioPill}
${statusPill}
</div>
<span class="text-xs ${textClass} truncate leading-tight">${escapeHtml(t.title)}</span>
</div>
</div>
<button type="button" onclick="deleteTaskInline('${t.task_id}')" class="text-slate-400 hover:text-red-500 p-1.5 transition rounded-lg hover:bg-red-50" title="Удалить задачу">
<i class="fa-solid fa-trash-can text-xs"></i>
</button>
</div>
`;
}).join("");
}
function renderInteractiveTaskCard(tasks) {
if (!tasks || !Array.isArray(tasks)) return "";
const widgetId = "task_widget_" + Date.now();
activeWidgetTasksMap.set(widgetId, { tasks: tasks, filter: "ALL" });
const itemsHtml = renderTaskItemsHtml(tasks, "ALL");
return `
<div class="task-widget-card mt-3 bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm space-y-3" id="${widgetId}">
<div class="flex items-center gap-1 pb-2 border-b border-slate-100 overflow-x-auto no-scrollbar text-xs font-semibold">
<button type="button" onclick="filterTaskWidget('${widgetId}', 'ALL', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-600 font-bold border border-indigo-200">Все</button>
<button type="button" onclick="filterTaskWidget('${widgetId}', 'IN_PROGRESS', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700">В работе</button>
<button type="button" onclick="filterTaskWidget('${widgetId}', 'BACKLOG', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700">В планах</button>
<button type="button" onclick="filterTaskWidget('${widgetId}', 'COMPLETED', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700">Завершенные</button>
</div>
<div class="widget-items-container space-y-2 max-h-72 overflow-y-auto pr-1">
${itemsHtml}
</div>
<div class="pt-2 border-t border-slate-100 flex items-center gap-2">
<input type="text" placeholder="Новая задача..." class="flex-1 bg-slate-50 border border-slate-200 rounded-xl px-3 py-1.5 text-xs text-slate-800 focus:outline-none focus:border-indigo-600 focus:bg-white transition" onkeydown="if(event.key==='Enter') addTaskInline('${widgetId}', this)">
<button type="button" onclick="addTaskInline('${widgetId}', this.previousElementSibling)" class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white px-3 py-1.5 rounded-xl text-xs font-semibold shadow-sm transition flex items-center gap-1">
<i class="fa-solid fa-plus text-[10px]"></i> Добавить
</button>
</div>
</div>
`;
}
function filterTaskWidget(widgetId, status, btnEl) {
const state = activeWidgetTasksMap.get(widgetId);
if (!state) return;
state.filter = status;
const widgetEl = document.getElementById(widgetId);
if (!widgetEl) return;
widgetEl.querySelectorAll(".widget-tab-btn").forEach(b => {
b.className = "widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700";
});
btnEl.className = "widget-tab-btn px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-600 font-bold border border-indigo-200";
const container = widgetEl.querySelector(".widget-items-container");
if (container) {
container.innerHTML = renderTaskItemsHtml(state.tasks, status);
}
}
async function toggleTaskStatusInline(taskId, isChecked) {
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const newStatus = isChecked ? "COMPLETED" : "BACKLOG";
try {
const res = await fetch(`/api/v1/tasks/${taskId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify({ status: newStatus })
});
if (res.ok) {
for (let [wId, state] of activeWidgetTasksMap.entries()) {
const targetTask = state.tasks.find(t => t.task_id === taskId);
if (targetTask) {
targetTask.status = newStatus;
const widgetEl = document.getElementById(wId);
const container = widgetEl?.querySelector(".widget-items-container");
if (container) {
container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter);
}
}
}
if (typeof loadTasks === "function") loadTasks();
}
} catch (err) {
console.error("[Task Patch Error]", err);
}
}
async function deleteTaskInline(taskId) {
if (!confirm(`Удалить задачу ${taskId}?`)) return;
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
try {
const res = await fetch(`/api/v1/tasks/${taskId}`, {
method: "DELETE",
headers: { "Authorization": "Bearer " + token }
});
if (res.ok) {
for (let [wId, state] of activeWidgetTasksMap.entries()) {
state.tasks = state.tasks.filter(t => t.task_id !== taskId);
const widgetEl = document.getElementById(wId);
const container = widgetEl?.querySelector(".widget-items-container");
if (container) {
container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter);
}
}
if (typeof loadTasks === "function") loadTasks();
}
} catch (err) {
console.error("[Task Delete Error]", err);
}
}
async function addTaskInline(widgetId, inputEl) {
if (!inputEl) return;
const title = inputEl.value.trim();
if (!title) return;
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
try {
const res = await fetch("/api/v1/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify({ title: title, priority: "MEDIUM", module: "general" })
});
if (res.ok) {
inputEl.value = "";
const tasksRes = await fetch("/api/v1/tasks", {
headers: { "Authorization": "Bearer " + token }
});
const updatedTasks = await tasksRes.json();
const state = activeWidgetTasksMap.get(widgetId);
if (state) {
state.tasks = updatedTasks;
const widgetEl = document.getElementById(widgetId);
const container = widgetEl?.querySelector(".widget-items-container");
if (container) {
container.innerHTML = renderTaskItemsHtml(updatedTasks, state.filter);
}
}
if (typeof loadTasks === "function") loadTasks();
}
} catch (err) {
console.error("[Task Add Error]", err);
}
}
// --- [CHAT SEND PIPELINE] ---
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>
`;
// Если выведены кнопки подтверждения превью — блокируем ввод с клавиатуры
if (actionData.type === "PROMPT_PREVIEW") {
setInputLocked(true);
} else {
setInputLocked(false);
}
} else {
setInputLocked(false);
}
let interactiveWidgetHtml = "";
if (actionData && actionData.type === "TASK_INTERACTIVE_CARD" && Array.isArray(actionData.tasks)) {
interactiveWidgetHtml = renderInteractiveTaskCard(actionData.tasks);
}
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>
${interactiveWidgetHtml}
${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;
setInputLocked(false);
} finally {
if (sendBtn && !document.getElementById("user-input")?.disabled) {
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();
if (input.disabled) return;
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);
}
});
+236 -54
View File
@@ -4,7 +4,7 @@ FILE: modules/web_api/static/js/chat/core.js
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / static / js / chat
ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, блокировка ввода
при активных кнопках, вызов онлайн-редактора и отправка правок.
при активных кнопках, вызов инлайн-редактора, Diff и карточки скачивания файлов.
===============================================================================
*/
@@ -79,11 +79,6 @@ function disableAllActionButtons() {
}
function handleActionButtonClick(text) {
if (text === "action:open_editor") {
openInlinePromptEditor();
return;
}
disableAllActionButtons();
setInputLocked(false);
const input = document.getElementById("user-input");
@@ -93,22 +88,120 @@ function handleActionButtonClick(text) {
}
}
function openInlinePromptEditor() {
const editorContainer = document.getElementById("inline-prompt-editor-container");
if (editorContainer) {
function escapeHtml(text) {
if (text === null || text === undefined) return "";
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// --- [INLINE PROMPT EDITOR & FULL DIFF BUILDER] ---
function openInlinePromptEditor(btnEl) {
const cardContainer = btnEl.closest(".chat-message-card");
if (!cardContainer) return;
const editorContainer = cardContainer.querySelector(".inline-prompt-editor-container");
const diffView = cardContainer.querySelector(".prompt-preview-diff-view");
const textarea = cardContainer.querySelector(".inline-prompt-textarea");
if (editorContainer && textarea) {
editorContainer.classList.remove("hidden");
const textarea = document.getElementById("inline-prompt-textarea");
if (textarea) textarea.focus();
if (diffView) {
const targetHeight = Math.max(diffView.offsetHeight, 320);
textarea.style.height = `${targetHeight}px`;
}
textarea.focus();
}
}
function closeInlinePromptEditor() {
const editorContainer = document.getElementById("inline-prompt-editor-container");
function closeInlinePromptEditor(btnEl) {
const cardContainer = btnEl.closest(".chat-message-card");
if (!cardContainer) return;
const editorContainer = cardContainer.querySelector(".inline-prompt-editor-container");
if (editorContainer) editorContainer.classList.add("hidden");
}
async function saveManualPromptDraft() {
const textarea = document.getElementById("inline-prompt-textarea");
function parsePromptIntoMap(text) {
const map = new Map();
if (!text) return map;
const lines = text.split("\n");
for (let line of lines) {
let trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^(\d+)[\.\s]+(\d+)[\.\s\:\-]*(.*)$/);
if (match) {
const key = `${match[1]}.${match[2]}`;
map.set(key, { sec: parseInt(match[1]), itm: parseInt(match[2]), content: match[3].trim(), full: trimmed });
}
}
return map;
}
function buildHighlightedPromptHtml(newRawText, originalBaselineText) {
const origMap = parsePromptIntoMap(originalBaselineText);
const newMap = parsePromptIntoMap(newRawText);
const formattedLines = [];
const rawLines = newRawText.split("\n");
const handledNewKeys = new Set();
let currentSection = null;
for (let line of rawLines) {
let trimmed = line.trim();
if (!trimmed) {
formattedLines.push("");
continue;
}
const secMatch = trimmed.match(/^(\d+)[\.\s\:\-]+([^\d].*)$/);
const subMatch = trimmed.match(/^(\d+)[\.\s]+(\d+)[\.\s\:\-]*(.*)$/);
if (subMatch) {
const key = `${subMatch[1]}.${subMatch[2]}`;
handledNewKeys.add(key);
currentSection = parseInt(subMatch[1]);
const cleanContent = subMatch[3].trim();
const fullItemStr = `${subMatch[1]}.${subMatch[2]}. ${cleanContent}`;
if (!origMap.has(key) || origMap.get(key).content !== cleanContent) {
formattedLines.push(` <span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">${escapeHtml(fullItemStr)}</span>`);
} else {
formattedLines.push(` ${escapeHtml(fullItemStr)}`);
}
} else if (secMatch && !anyLower(secMatch[2].slice(0, 15))) {
currentSection = parseInt(secMatch[1]);
formattedLines.push(escapeHtml(trimmed));
} else {
formattedLines.push(escapeHtml(trimmed));
}
}
for (let [origKey, origObj] of origMap.entries()) {
if (!handledNewKeys.has(origKey)) {
const strikeMarkup = ` <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">${origKey}. ${escapeHtml(origObj.content)} [УДАЛЕНИЕ]</span>`;
formattedLines.push(strikeMarkup);
}
}
return formattedLines.join("\n");
}
function anyLower(str) {
return /[а-яa-z]/.test(str);
}
async function saveManualPromptDraft(btnEl) {
const cardContainer = btnEl.closest(".chat-message-card");
if (!cardContainer) return;
const textarea = cardContainer.querySelector(".inline-prompt-textarea");
const previewTextEl = cardContainer.querySelector(".prompt-preview-diff-view");
if (!textarea) return;
const newText = textarea.value.trim();
@@ -130,29 +223,59 @@ async function saveManualPromptDraft() {
});
if (res.ok) {
closeInlinePromptEditor();
const previewTextEl = document.getElementById("prompt-preview-diff-view");
closeInlinePromptEditor(btnEl);
if (previewTextEl) {
previewTextEl.innerText = newText;
const baseline = cardContainer.dataset.originalBaseline || "";
const highlightedHtml = buildHighlightedPromptHtml(newText, baseline);
previewTextEl.innerHTML = highlightedHtml;
}
let actionButtonsContainer = cardContainer.querySelector(".action-buttons-container");
if (actionButtonsContainer) {
actionButtonsContainer.innerHTML = `
<button type="button" onclick="handleActionButtonClick('подтверждаю')"
class="bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
<i class="fa-solid fa-check"></i>
<span>Подтвердить</span>
</button>
<button type="button" onclick="handleActionButtonClick('отмена')"
class="bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300 font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
<i class="fa-solid fa-xmark"></i>
<span>Отменить</span>
</button>
<button type="button" onclick="openInlinePromptEditor(this)"
class="bg-indigo-50 hover:bg-indigo-100 active:bg-indigo-200 text-indigo-700 border border-indigo-300 font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
<i class="fa-solid fa-pen-to-square text-xs"></i>
<span>✏️ Редактировать</span>
</button>
`;
}
alert("✓ Правки сохранены в черновике! Нажмите «Подтвердить» для применения в БД.");
}
} catch (err) {
alert("Ошибка сохранения черновика: " + err);
}
}
function escapeHtml(text) {
if (!text) return "";
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
// ⭐️ Абсолютный расчет позиции скролла к верху видимой области
function scrollToTurnTop(anchorId) {
const chatWindow = document.getElementById("chat-window");
const anchorEl = document.getElementById(anchorId);
if (!chatWindow || !anchorEl) return;
const windowRect = chatWindow.getBoundingClientRect();
const anchorRect = anchorEl.getBoundingClientRect();
const currentScroll = chatWindow.scrollTop;
// Новая позиция скролла = текущий сдвиг + расстояние от верхней кромки окна до элемента
const targetTop = currentScroll + (anchorRect.top - windowRect.top) - 12;
chatWindow.scrollTo({
top: Math.max(0, targetTop),
behavior: "smooth"
});
}
// --- [CHAT SEND PIPELINE] ---
// --- [CHAT SEND PIPELINE (GEMINI STYLE TOP-SCROLL)] ---
async function sendMessage(e) {
if (e && e.preventDefault) e.preventDefault();
@@ -174,18 +297,29 @@ async function sendMessage(e) {
</div>` + userDisplayHtml;
}
const turnId = "turn-" + Date.now();
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">
<div id="${turnId}" class="chat-turn-anchor flex justify-end mb-3 pt-2 scroll-mt-3">
<div class="bg-indigo-600 text-white rounded-2xl px-4 py-2.5 max-w-xl text-xs sm:text-sm shadow-sm leading-relaxed">
${userDisplayHtml}
</div>
</div>
`;
chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
// Вставляем вопрос строго перед невидимой распоркой
const spacer = document.getElementById("chat-bottom-spacer");
if (spacer) {
spacer.insertAdjacentHTML("beforebegin", userMsgHtml);
} else {
chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
}
input.value = "";
updateInputHeight(input);
chatWindow.scrollTop = chatWindow.scrollHeight;
// Скроллим сразу при отправке вопроса
setTimeout(() => scrollToTurnTop(turnId), 50);
if (sendBtn) {
sendBtn.disabled = true;
@@ -250,8 +384,10 @@ async function sendMessage(e) {
iconMarkup = '<i class="fa-solid fa-pen-to-square text-xs"></i>';
}
const clickHandler = isEdit ? "openInlinePromptEditor(this)" : `handleActionButtonClick('${escapeHtml(btn.value)}')`;
return `
<button type="button" onclick="handleActionButtonClick('${escapeHtml(btn.value)}')"
<button type="button" onclick="${clickHandler}"
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>
@@ -265,21 +401,25 @@ async function sendMessage(e) {
</div>
`;
// Блокируем ввод при наличии кнопок
setInputLocked(true);
} else {
setInputLocked(false);
}
let previewEditorHtml = "";
if (actionData && actionData.type === "PROMPT_PREVIEW" && actionData.raw_draft) {
let rawDraftText = "";
let baselineText = "";
if (actionData && actionData.type === "PROMPT_PREVIEW") {
rawDraftText = actionData.raw_draft || "";
baselineText = actionData.baseline_prompt || rawDraftText;
previewEditorHtml = `
<div id="inline-prompt-editor-container" class="hidden mt-3 p-3 bg-slate-50 border border-slate-300 rounded-xl space-y-2">
<div class="inline-prompt-editor-container hidden mt-3 p-3 bg-slate-50 border border-slate-300 rounded-xl space-y-2">
<p class="text-[11px] font-bold text-slate-700 uppercase"><i class="fa-solid fa-pen-to-square mr-1"></i> Ручное редактирование текста промпта:</p>
<textarea id="inline-prompt-textarea" rows="10" class="w-full bg-white border border-slate-300 rounded-lg p-2.5 text-xs font-mono text-slate-800 focus:outline-none focus:border-indigo-600">${escapeHtml(actionData.raw_draft)}</textarea>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeInlinePromptEditor()" class="px-3 py-1.5 rounded-lg text-xs text-slate-600 bg-slate-200 hover:bg-slate-300">Свернуть</button>
<button type="button" onclick="saveManualPromptDraft()" class="px-3 py-1.5 rounded-lg text-xs font-semibold text-white bg-indigo-600 hover:bg-indigo-700 shadow-sm">Сохранить правки</button>
<textarea class="inline-prompt-textarea w-full bg-white border border-slate-300 rounded-lg p-3 text-xs font-mono text-slate-800 focus:outline-none focus:border-indigo-600 resize-y shadow-inner leading-relaxed">${escapeHtml(rawDraftText)}</textarea>
<div class="flex justify-end gap-2 pt-1">
<button type="button" onclick="closeInlinePromptEditor(this)" class="px-3 py-1.5 rounded-lg text-xs text-slate-600 bg-slate-200 hover:bg-slate-300 transition">Свернуть</button>
<button type="button" onclick="saveManualPromptDraft(this)" class="px-3.5 py-1.5 rounded-lg text-xs font-semibold text-white bg-indigo-600 hover:bg-indigo-700 shadow-sm transition">Сохранить правки</button>
</div>
</div>
`;
@@ -290,19 +430,57 @@ async function sendMessage(e) {
interactiveWidgetHtml = renderInteractiveTaskCard(actionData.tasks);
}
let snapshotsWidgetHtml = "";
if (actionData && actionData.type === "SNAPSHOTS_CARD" && typeof renderSnapshotsCard === "function") {
snapshotsWidgetHtml = renderSnapshotsCard(actionData.data);
}
let fileDownloadHtml = "";
if (actionData && actionData.type === "FILE_DOWNLOAD_CARD") {
fileDownloadHtml = `
<div class="mt-3 p-3 bg-emerald-50 border border-emerald-200 rounded-xl flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<i class="fa-solid fa-file-lines text-emerald-600 text-lg shrink-0"></i>
<div class="truncate">
<div class="text-xs font-bold text-slate-900 truncate">${escapeHtml(actionData.filename)}</div>
<div class="text-[11px] text-emerald-700">Готов к скачиванию (задач: ${actionData.tasks_count || '—'})</div>
</div>
</div>
<a href="${actionData.download_url}" download="${escapeHtml(actionData.filename)}"
class="px-3.5 py-1.5 bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-semibold rounded-lg text-xs flex items-center gap-1.5 transition shrink-0 shadow-sm">
<i class="fa-solid fa-download"></i>
<span>Скачать</span>
</a>
</div>
`;
}
const isWideWidget = actionData && (actionData.type === "TASK_INTERACTIVE_CARD" || actionData.type === "PROMPT_PREVIEW");
const maxWidthClass = isWideWidget ? "max-w-4xl w-full" : "max-w-2xl";
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>
<div class="text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed" id="prompt-preview-diff-view">${replyText}</div>
${previewEditorHtml}
${interactiveWidgetHtml}
${actionButtonsHtml}
</div>
`;
chatWindow.insertAdjacentHTML("beforeend", botMsgHtml);
chatWindow.scrollTop = chatWindow.scrollHeight;
<div class="chat-message-card bg-white border border-slate-200 rounded-2xl p-4 shadow-sm w-full mb-3" data-original-baseline="${escapeHtml(baselineText)}">
<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>
<div class="prompt-preview-diff-view text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${replyText}</div>
${previewEditorHtml}
${interactiveWidgetHtml}
${snapshotsWidgetHtml}
${fileDownloadHtml}
${actionButtonsHtml}
</div>
`;
// Вставляем карточку ответа строго перед невидимой распоркой
if (spacer) {
spacer.insertAdjacentHTML("beforebegin", botMsgHtml);
} else {
chatWindow.insertAdjacentHTML("beforeend", botMsgHtml);
}
// Скроллим к началу хода после рендера карточки ответа
setTimeout(() => scrollToTurnTop(turnId), 100);
clearAttachedFile();
@@ -317,8 +495,12 @@ async function sendMessage(e) {
Ошибка связи с сервером.
</div>
`;
chatWindow.insertAdjacentHTML("beforeend", errorHtml);
chatWindow.scrollTop = chatWindow.scrollHeight;
if (spacer) {
spacer.insertAdjacentHTML("beforebegin", errorHtml);
} else {
chatWindow.insertAdjacentHTML("beforeend", errorHtml);
}
setTimeout(() => scrollToTurnTop(turnId), 50);
setInputLocked(false);
} finally {
const inputEl = document.getElementById("user-input");
+393 -156
View File
@@ -1,208 +1,445 @@
/**
/*
===============================================================================
FILE: modules/web_api/static/js/chat/task_widget.js
ROLE: Генеративный UI интерактивных карточек задач внутри диалога чата
(фильтры, inline-чекбоксы статусов, быстрое добавление и удаление).
ROLE: Интерактивные виджеты задач и срезов СКУД (SNAPSHOTS_CARD) с чекбоксами.
===============================================================================
*/
let activeWidgetTasksMap = new Map();
window.activeTaskFilter = window.activeTaskFilter || 'IN_PROGRESS';
window.currentTasksCache = window.currentTasksCache || [];
function renderTaskItemsHtml(tasks, filterStatus) {
const filtered = tasks.filter(t => {
if (filterStatus === "ALL") return true;
return t.status === filterStatus;
});
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;
}
if (filtered.length === 0) {
const label = filterStatus === 'BACKLOG' ? 'В планах' : filterStatus === 'IN_PROGRESS' ? 'В работе' : filterStatus === 'COMPLETED' ? 'Завершенные' : 'Все';
return `<div class="text-slate-400 text-xs py-4 text-center">Нет задач со статусом «${label}»</div>`;
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>
`;
}
return filtered.map(t => {
const isDone = t.status === "COMPLETED";
const isProgress = t.status === "IN_PROGRESS";
const checkedAttr = isDone ? "checked" : "";
const textClass = isDone ? "line-through text-slate-400" : "text-slate-800 font-medium";
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');
let statusPill = `<span class="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded border border-slate-200">В планах</span>`;
if (isDone) {
statusPill = `<span class="text-[10px] bg-emerald-50 text-emerald-700 px-2 py-0.5 rounded border border-emerald-300 font-semibold">Завершено</span>`;
} else if (isProgress) {
statusPill = `<span class="text-[10px] bg-amber-50 text-amber-700 px-2 py-0.5 rounded border border-amber-300 font-semibold">В работе</span>`;
}
let prioPill = "";
if (t.priority === "HIGH") {
prioPill = `<span class="text-[9px] bg-red-50 text-red-700 font-bold px-1.5 py-0.5 rounded border border-red-200 uppercase">HIGH</span>`;
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="flex items-center justify-between p-2.5 bg-slate-50 hover:bg-slate-100/80 rounded-xl border border-slate-200 transition gap-2" id="widget-task-${t.task_id}">
<div class="flex items-center gap-2.5 flex-1 min-w-0">
<input type="checkbox" ${checkedAttr} onchange="toggleTaskStatusInline('${t.task_id}', this.checked)"
class="w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500 cursor-pointer">
<div class="flex flex-col min-w-0 flex-1">
<div class="flex items-center gap-1.5 mb-0.5">
<span class="font-mono text-[11px] font-bold text-slate-700">${t.task_id}</span>
${prioPill}
${statusPill}
</div>
<span class="text-xs ${textClass} truncate leading-tight">${escapeHtml(t.title)}</span>
<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="deleteTaskInline('${t.task_id}')" class="text-slate-400 hover:text-red-500 p-1.5 transition rounded-lg hover:bg-red-50" title="Удалить задачу">
<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("");
}
function renderInteractiveTaskCard(tasks) {
if (!tasks || !Array.isArray(tasks)) return "";
const widgetId = "task_widget_" + Date.now();
activeWidgetTasksMap.set(widgetId, { tasks: tasks, filter: "ALL" });
const itemsHtml = renderTaskItemsHtml(tasks, "ALL");
}).join('');
return `
<div class="task-widget-card mt-3 bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm space-y-3" id="${widgetId}">
<div class="flex items-center gap-1 pb-2 border-b border-slate-100 overflow-x-auto no-scrollbar text-xs font-semibold">
<button type="button" onclick="filterTaskWidget('${widgetId}', 'ALL', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-600 font-bold border border-indigo-200">Все</button>
<button type="button" onclick="filterTaskWidget('${widgetId}', 'IN_PROGRESS', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700">В работе</button>
<button type="button" onclick="filterTaskWidget('${widgetId}', 'BACKLOG', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700">В планах</button>
<button type="button" onclick="filterTaskWidget('${widgetId}', 'COMPLETED', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700">Завершенные</button>
<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="widget-items-container space-y-2 max-h-72 overflow-y-auto pr-1">
${itemsHtml}
<div class="p-3 flex flex-col gap-2">
${rowsHtml}
</div>
<div class="pt-2 border-t border-slate-100 flex items-center gap-2">
<input type="text" placeholder="Новая задача..." class="flex-1 bg-slate-50 border border-slate-200 rounded-xl px-3 py-1.5 text-xs text-slate-800 focus:outline-none focus:border-indigo-600 focus:bg-white transition" onkeydown="if(event.key==='Enter') addTaskInline('${widgetId}', this)">
<button type="button" onclick="addTaskInline('${widgetId}', this.previousElementSibling)" class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white px-3 py-1.5 rounded-xl text-xs font-semibold shadow-sm transition flex items-center gap-1">
<i class="fa-solid fa-plus text-[10px]"></i> Добавить
<!-- ПОДВАЛ С КНОПКОЙ УДАЛЕНИЯ ВЫБРАННЫХ -->
<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>
`;
}
function filterTaskWidget(widgetId, status, btnEl) {
const state = activeWidgetTasksMap.get(widgetId);
if (!state) return;
state.filter = status;
// ⭐️ Обработчики чекбоксов
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);
};
const widgetEl = document.getElementById(widgetId);
if (!widgetEl) return;
window.updateSelectedSnapshots = function(itemCb) {
const root = itemCb.closest('.snapshots-widget-root');
if (!root) return;
window.syncBulkDeleteFooter(root);
};
widgetEl.querySelectorAll(".widget-tab-btn").forEach(b => {
b.className = "widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700";
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;
});
btnEl.className = "widget-tab-btn px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-600 font-bold border border-indigo-200";
const container = widgetEl.querySelector(".widget-items-container");
if (container) {
container.innerHTML = renderTaskItemsHtml(state.tasks, status);
}
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>
`;
}
async function toggleTaskStatusInline(taskId, isChecked) {
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const newStatus = isChecked ? "COMPLETED" : "BACKLOG";
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/${taskId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify({ status: newStatus })
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) {
for (let [wId, state] of activeWidgetTasksMap.entries()) {
const targetTask = state.tasks.find(t => t.task_id === taskId);
if (targetTask) {
targetTask.status = newStatus;
const widgetEl = document.getElementById(wId);
const container = widgetEl?.querySelector(".widget-items-container");
if (container) {
container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter);
}
}
}
if (typeof loadTasks === "function") loadTasks();
window.activeTaskFilter = (statusInput && statusInput.value === 'IN_PROGRESS') ? 'IN_PROGRESS' : 'PLANNED';
window.sendChatAction('покажи задачи');
} else {
const err = await res.json();
alert('Ошибка создания задачи: ' + (err.detail || 'Неизвестная ошибка'));
}
} catch (err) {
console.error("[Task Patch Error]", err);
} catch (e) {
console.error('Ошибка создания задачи:', e);
alert('Сетевая ошибка при создании задачи');
}
}
};
async function deleteTaskInline(taskId) {
if (!confirm(`Удалить задачу ${taskId}?`)) return;
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
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/${taskId}`, {
method: "DELETE",
headers: { "Authorization": "Bearer " + token }
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) {
for (let [wId, state] of activeWidgetTasksMap.entries()) {
state.tasks = state.tasks.filter(t => t.task_id !== taskId);
const widgetEl = document.getElementById(wId);
const container = widgetEl?.querySelector(".widget-items-container");
if (container) {
container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter);
}
}
if (typeof loadTasks === "function") loadTasks();
window.sendChatAction('покажи задачи');
} else {
const err = await res.json();
alert('Ошибка обновления задачи: ' + (err.detail || 'Неизвестная ошибка'));
}
} catch (err) {
console.error("[Task Delete Error]", err);
} catch (e) {
console.error('Ошибка сохранения задачи:', e);
alert('Сетевая ошибка при обновлении задачи');
}
}
async function addTaskInline(widgetId, inputEl) {
if (!inputEl) return;
const title = inputEl.value.trim();
if (!title) return;
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
try {
const res = await fetch("/api/v1/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify({ title: title, priority: "MEDIUM", module: "general" })
});
if (res.ok) {
inputEl.value = "";
const tasksRes = await fetch("/api/v1/tasks", {
headers: { "Authorization": "Bearer " + token }
});
const updatedTasks = await tasksRes.json();
const state = activeWidgetTasksMap.get(widgetId);
if (state) {
state.tasks = updatedTasks;
const widgetEl = document.getElementById(widgetId);
const container = widgetEl?.querySelector(".widget-items-container");
if (container) {
container.innerHTML = renderTaskItemsHtml(updatedTasks, state.filter);
}
}
if (typeof loadTasks === "function") loadTasks();
}
} catch (err) {
console.error("[Task Add Error]", err);
}
}
};
+84 -131
View File
@@ -1,164 +1,117 @@
/**
===============================================================================
FILE: modules/web_api/static/js/tasks.js
ROLE: Управление боковой панелью задач (Drawer), фильтрация и рендеринг карточек.
AI-CONTEXT-ANCHORS:
- ANCHOR[DRAWER_TOGGLE]: Открытие и закрытие выезжающей панели.
- ANCHOR[TASKS_FILTER]: Фильтрация списка (ALL, IN_PROGRESS, BACKLOG, COMPLETED).
- ANCHOR[TASKS_LOAD_FETCH]: Асинхронная загрузка задач через /api/v1/tasks.
- ANCHOR[TASKS_RENDER_DOM]: Генерация HTML-карточек в боковом меню.
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / static / js
ROLE: Загрузка, фильтрация и рендеринг списка задач в боковой панели (Drawer).
===============================================================================
*/
let currentFilter = 'ALL';
let allTasks = [];
let currentTaskFilter = 'IN_PROGRESS';
// --- [SECTION 1: DRAWER TOGGLE] --- # ANCHOR[DRAWER_TOGGLE]
function toggleDrawer() {
if (typeof IS_GUEST !== 'undefined' && IS_GUEST) return;
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");
}
}
// --- [SECTION 2: FILTER CONTROL] --- # ANCHOR[TASKS_FILTER]
function setFilter(status) {
currentFilter = status;
["ALL", "IN_PROGRESS", "BACKLOG", "COMPLETED"].forEach(f => {
const btn = document.getElementById(`filter-${f}`);
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();
}
// --- [SECTION 3: ASYNC DATA FETCH] --- # ANCHOR[TASKS_LOAD_FETCH]
async function loadTasks() {
const badge = document.getElementById("task-count-badge");
const container = document.getElementById("tasks-container");
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");
if (isGuest || !token) {
if (badge) badge.innerText = "0";
return;
}
if (!token) return;
try {
const res = await fetch("/api/v1/tasks", {
headers: {
"Authorization": "Bearer " + token,
"Content-Type": "application/json"
}
headers: { "Authorization": "Bearer " + token }
});
if (res.status === 401) {
if (typeof logout === 'function') logout();
return;
}
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const data = await res.json();
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)) || [];
if (res.ok) {
const data = await res.json();
renderSidebarTasks(data.tasks || []);
} else {
allTasks = [];
}
if (badge) {
badge.innerText = allTasks.length.toString();
}
renderTasks();
} catch (err) {
console.error("[Tasks Error]", err);
if (badge) badge.innerText = "0";
if (container) {
container.innerHTML = `<div class="text-red-500 text-xs py-8 text-center font-medium">Ошибка обработки списка задач</div>`;
renderSidebarError("Ошибка доступа. Авторизуйтесь снова.");
}
} catch (e) {
console.error("Ошибка загрузки задач:", e);
renderSidebarError("Ошибка сети. Сервер недоступен.");
}
}
// --- [SECTION 4: DOM RENDERING] --- # ANCHOR[TASKS_RENDER_DOM]
function renderTasks() {
const container = document.getElementById("tasks-container");
function renderSidebarError(msg) {
// Поддерживаем оба варианта ID (новый и старый) для обратной совместимости
const container = document.getElementById("tasks-list") || document.getElementById("tasks-list-container");
if (container) {
container.innerHTML = `<div class="text-center py-8 text-xs text-rose-500 font-semibold">${msg}</div>`;
}
}
function filterTasksByTab(status) {
currentTaskFilter = status;
// Сброс стилей всех кнопок-вкладок
document.querySelectorAll('.task-tab-btn').forEach(btn => {
btn.classList.remove('text-indigo-600', 'bg-indigo-50');
btn.classList.add('text-slate-600');
});
// Установка активного стиля для выбранной вкладки
const activeBtnId = {
'IN_PROGRESS': 'tab-in-progress',
'BACKLOG': 'tab-backlog',
'COMPLETED': 'tab-completed',
'ALL': 'tab-all'
}[status];
if (activeBtnId) {
const btn = document.getElementById(activeBtnId);
if (btn) {
btn.classList.remove('text-slate-600', 'hover:bg-slate-100');
btn.classList.add('text-indigo-600', 'bg-indigo-50');
}
}
loadTasks();
}
function renderSidebarTasks(tasks) {
const container = document.getElementById("tasks-list") || document.getElementById("tasks-list-container");
if (!container) return;
if (!Array.isArray(allTasks)) {
allTasks = [];
let filtered = tasks;
if (currentTaskFilter !== 'ALL') {
filtered = tasks.filter(t => t.status === currentTaskFilter);
}
const filtered = allTasks.filter(t => currentFilter === "ALL" || t.status === currentFilter);
if (filtered.length === 0) {
container.innerHTML = `<div class="text-slate-400 text-xs py-8 text-center">Нет задач с выбранным фильтром</div>`;
container.innerHTML = `<div class="text-center py-8 text-[11px] font-medium text-slate-400 bg-slate-50 rounded-xl border border-dashed border-slate-200">Нет задач в этой категории</div>`;
return;
}
container.innerHTML = filtered.map(t => {
let statusBadge = "bg-slate-100 text-slate-600 border-slate-200";
let statusLabel = "В планах";
let cardBg = "bg-white";
const isCompleted = t.status === 'COMPLETED';
const priorityColor = t.priority === 'HIGH' || t.priority === 'CRITICAL'
? 'text-rose-600 bg-rose-50 border-rose-200'
: t.priority === 'MEDIUM'
? 'text-amber-600 bg-amber-50 border-amber-200'
: 'text-slate-600 bg-slate-50 border-slate-200';
if (t.status === "COMPLETED") {
statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold";
statusLabel = "Завершено";
cardBg = "bg-emerald-50/20";
} else if (t.status === "IN_PROGRESS") {
statusBadge = "bg-amber-50 text-amber-700 border-amber-300 font-bold";
statusLabel = "В работе";
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 `
<div class="${cardBg} border border-slate-200 rounded-xl p-3.5 shadow-sm hover:shadow-md transition">
<div class="flex justify-between items-center mb-1.5">
<div class="flex items-center gap-1.5">
<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>
<span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'MEDIUM'}</span>
</div>
<span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${statusLabel}</span>
<div class="p-3 bg-white border border-slate-200 rounded-xl shadow-sm hover:border-indigo-400 hover:shadow-md transition cursor-pointer flex flex-col gap-2 group"
onclick="handleActionButtonClick('покажи задачу ${t.id}')">
<div class="flex items-start justify-between gap-2">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-wider group-hover:text-indigo-500 transition">#${t.id}</span>
${isCompleted
? `<span class="text-[10px] font-bold px-1.5 py-0.5 rounded-md bg-emerald-50 text-emerald-600 border border-emerald-200 shadow-sm"><i class="fa-solid fa-check mr-0.5"></i> Готово</span>`
: `<span class="text-[10px] font-bold px-1.5 py-0.5 rounded-md ${priorityColor} shadow-sm">${t.priority || 'LOW'}</span>`
}
</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">
<i class="fa-solid fa-folder-closed text-slate-300"></i>
<span>${t.module || 'General'}</span>
<div class="text-xs font-semibold text-slate-700 leading-snug line-clamp-3">${escapeHtml(t.title || 'Без названия')}</div>
<div class="flex items-center justify-between text-[10px] text-slate-400 mt-1">
<span class="bg-slate-100 px-1.5 py-0.5 rounded font-mono truncate max-w-[120px]">${escapeHtml(t.module || 'general')}</span>
${t.due_date ? `<span class="shrink-0 font-medium text-slate-500"><i class="fa-regular fa-calendar mr-1"></i>${escapeHtml(t.due_date)}</span>` : ''}
</div>
${dueDateHtml}
</div>
`;
}).join("");
}
}).join('');
}
// Глобальная инициализация при загрузке DOM
document.addEventListener("DOMContentLoaded", () => {
// Небольшая задержка, чтобы гарантировать применение токена
setTimeout(loadTasks, 200);
});
+4637 -2649
View File
File diff suppressed because it is too large Load Diff
+56 -10
View File
@@ -51,12 +51,12 @@ def print_stats():
print("=" * 60)
with get_connection() as conn:
cursor = conn.cursor()
tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states']
tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks']
for t in tables:
try:
cursor.execute(f"SELECT COUNT(*) FROM {t}")
cnt = cursor.fetchone()[0]
print(f" • Таблица [{t:<18}]: {cnt:>6} записей")
print(f" • Таблица [{t:<20}]: {cnt:>6} записей")
except Exception:
pass
print("=" * 60 + "\n")
@@ -151,7 +151,7 @@ def inspect_scud(snapshot_id=None, date_str=None, export_xlsx=None):
df = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_id)
if df.empty:
print(f"Записи СКУД не найдены.")
print("Записи СКУД не найдены.")
print("=" * 90 + "\n")
return
@@ -318,7 +318,6 @@ def print_chat_messages(session_id=None, limit=50):
if not rows:
print("Таблица chat_messages пуста.")
else:
# Если выводили все без конкретной сессии в порядке DESC, развернем для читаемости
if not session_id:
rows = list(reversed(rows))
@@ -327,13 +326,50 @@ def print_chat_messages(session_id=None, limit=50):
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):
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 '%1. РОЛЬ И ЗАДАЧИ АССИСТЕНТА%'
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")
HELP_TEXT = """
CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db)
@@ -348,6 +384,7 @@ CLI-утилита инспекции и управления SQLite базой
tools -- Посмотреть реестр действий инструментов и кнопок (tool_action_registry)
sessions -- Посмотреть активные сессии и превью (session_states)
context [session_id] [--limit N] -- Посмотреть таблицу контекста сообщений чата (chat_messages)
context purge [session_id] [--all] -- Очистить контекст (умная зачистка или полная с флагом --all)
dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel
snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшота по ID или всех за выбранный день
@@ -360,6 +397,9 @@ CLI-утилита инспекции и управления SQLite базой
python scripts/db_cli.py tools
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 dump my_dump.xlsx
"""
@@ -376,12 +416,13 @@ def main():
add_help=False
)
parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot', 'tools', 'context'], help="Основная команда")
parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del') или session_id для контекста")
parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота или имя файла)")
parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del', 'purge') или session_id для контекста")
parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота, session_id, '--all' или имя файла)")
parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции")
parser.add_argument('--export-xlsx', type=str, default=None, help="Экспорт среза СКУД в Excel-файл")
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:
print_stats()
@@ -410,8 +451,13 @@ def main():
elif args.command == 'tools':
print_tool_actions()
elif args.command == 'context':
sess_id = args.action if args.action else None
print_chat_messages(session_id=sess_id, limit=args.limit)
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':
filename = args.param if args.param else "db_dump_full.xlsx"
dump_all_to_excel(filename)
@@ -428,7 +474,7 @@ def main():
print(f"\n[❌] Ошибка: Неизвестное действие '{args.action}' для команды snapshot.")
print("Используйте: python scripts/db_cli.py snapshot del [ID или --day 'ДД.ММ.ГГГГ']\n")
else:
print(f"\n[❌] Ошибка: Неизвестная команда.")
print("\n[❌] Ошибка: Неизвестная команда.")
print(HELP_TEXT)
if __name__ == "__main__":
+23 -8
View File
@@ -2,15 +2,29 @@ import os
OUTPUT_SNAPSHOT = "project_code_snapshot.md"
# Расширения файлов для включения в снимок
ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini', '.js', '.html', '.css', '.sql'}
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_orion_ai_v2.tar.gz', 'context_memory.db'}
# Расширения файлов для включения в снимок (только исходный код)
ALLOWED_EXTENSIONS = {'.py', '.json', '.sh', '.ini', '.js', '.html', '.css', '.sql'}
print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...")
# Исключаемые каталоги (убираем docs, кэши, архивы и окружения)
EXCLUDE_DIRS = {
'.git', '__pycache__', 'venv', '.venv', 'output', 'logs',
'extracted_project', 'docs', 'data'
}
# Исключаемые файлы
EXCLUDE_FILES = {
OUTPUT_SNAPSHOT,
'api_code_snapshot.md',
'project_code_snapshot.md',
'scud_orion_ai_v2.tar.gz',
'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_orion_ai_v2\n\n")
out.write("# 📦 ОПТИМИЗИРОВАННЫЙ СЛЕПОК ИСХОДНОГО КОДА scud_orion_ai\n\n")
for root, dirs, files in os.walk('.'):
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
@@ -20,7 +34,7 @@ with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out:
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")
out.write("```" + ext.replace('.', '') + "\n")
try:
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
out.write(f.read())
@@ -28,4 +42,5 @@ with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out:
out.write(f"// Ошибка чтения файла: {e}\n")
out.write("\n```\n\n")
print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)")
size_kb = os.path.getsize(OUTPUT_SNAPSHOT) / 1024
print(f"✓ Слепок успешно создан: {OUTPUT_SNAPSHOT} ({size_kb:.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:
return {}
# 🛡 Исключаем точные совпадения (чтобы ИИ не совершал ложных замен)
# Исключаем точные совпадения
staff_fios_set = set(staff_fios)
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"""
Ты — кадровый аудитор безопасности СКУД.
{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)}
СТРОГИЕ ПРАВИЛА:
@@ -173,40 +173,59 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
4. В поле "warning" опиши обнаруженную опечатку.
5. Запрещено выдумывать опечатки и объединять разных людей/однофамильцев!
ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ JSON!
Формат ответа:
ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ ВАЛИДНОГО JSON:
{{
"verified_matches": [
{{
"scud_fio": "ФИО из СКУД",
"staff_fio": "эталон ФИО из Штат 1С",
"warning": "Описание опечатки в СКУД или 'Точное совпадение (без опечаток)'"
"warning": "Описание опечатки в СКУД"
}}
]
}}
"""
raw_response = ask_ollama(
prompt,
system_prompt="Ты — JSON API. Выдавай ТОЛЬКО валидный JSON без markdown-разметки."
system_prompt="Ты — строгий JSON API генератор. Отвечай только валидным JSON объектом без пояснительного текста."
)
mapping = {}
if not raw_response:
return mapping
# Фаза 1: Попытка прямого разбора с санитарной очисткой
try:
match = re.search(r'\{.*\}', raw_response, re.DOTALL)
if match:
json_str = match.group(0)
json_str = re.sub(r'\}\s*[^}\]]*$', '}', json_str)
# Убираем висячие запятые: {"a": 1,} -> {"a": 1}
json_str = re.sub(r',\s*([\}\]])', r'\1', json_str)
# Заменяем одинарные кавычки в ключах/значениях на двойные при необходимости
json_str = re.sub(r"(?<=\{|\,)\s*'([^']+)'\s*:", r'"\1":', json_str)
data = json.loads(json_str)
for item in data.get("verified_matches", []):
scud_f = item.get("scud_fio")
staff_f = item.get("staff_fio")
warn = item.get("warning", "Точное совпадение (без опечаток)")
if scud_f and staff_f:
if scud_f and staff_f and staff_f in staff_fios_set:
mapping[scud_f] = {"staff_fio": staff_f, "warning": warn}
return mapping
except Exception:
pass
# Фаза 2: Резервный Regex-парсер (если JSON синтаксически сломан, но пары ключ-значение есть)
try:
pattern = r'["\']scud_fio["\']\s*:\s*["\']([^"\']+)["\'].*?["\']staff_fio["\']\s*:\s*["\']([^"\']+)["\']'
matches = re.findall(pattern, raw_response, re.DOTALL)
for scud_f, staff_f in matches:
scud_clean = scud_f.strip()
staff_clean = staff_f.strip()
if staff_clean in staff_fios_set:
mapping[scud_clean] = {"staff_fio": staff_clean, "warning": "Восстановлено парсером опечаток"}
except Exception as e:
print(f"[!] Ошибка разбора JSON от ИИ при сверке опечаток: {e}")
print(f"[!] Ошибка резервного парсинга опечаток: {e}")
return mapping
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)
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
+51
View File
@@ -0,0 +1,51 @@
"""
===============================================================================
FILE: services/scud_etl/anomaly_detector.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / scud_etl
ROLE: Детектирование истинных аномалий и конфликтов реестров.
(Приход удаленщиков и командированных в офис аномалией НЕ является).
===============================================================================
"""
import logging
from typing import List, Dict, Any
import pandas as pd
logger = logging.getLogger("SCUD_ANOMALY")
def detect_registry_anomalies(df_merged: pd.DataFrame) -> List[Dict[str, Any]]:
"""
Выявляет реальные аномалии:
- Приход в офис во время отпуска или больничного листа.
- Ошибки считывателей СКУД (наличие выхода при отсутствии отметки входа).
"""
anomalies = []
for _, row in df_merged.iterrows():
fio = row.get("fio_clean") or row.get("Сотрудник", "")
start_day = str(row.get("Начало_дня", "")).strip()
end_day = str(row.get("Конец_дня", "")).strip()
reason = str(row.get("причина отсутствия", "")).strip()
reason_lower = reason.lower()
# 1. Приход в офис при отпуске / больничном
if start_day != "Нет входа" and reason and reason != "nan":
# Удаленная работа и командировки разрешены для работы в офисе
if not ("удален" in reason_lower or "дистанцион" in reason_lower or "командировк" in reason_lower or "поездк" in reason_lower):
anomalies.append({
"fio": fio,
"type": "PHYSICAL_PRESENCE_DURING_ABSENCE",
"description": f"Сотрудник пришел по СКУД ({start_day}), но в 1С оформлен документ: '{reason}'."
})
# 2. Аномалия оборудования (есть выход без входа)
if start_day == "Нет входа" and end_day != "Нет выхода":
anomalies.append({
"fio": fio,
"type": "SCUD_EQUIPMENT_ANOMALY",
"description": f"Отсутствует отметка утреннего входа при наличии выхода ({end_day})."
})
return anomalies
+112
View File
@@ -0,0 +1,112 @@
"""
===============================================================================
FILE: services/scud_etl/merger.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / scud_etl
ROLE: Агрегация реестров СКУД и 1С, наложение исключений, нормализация кодов
подразделений и расчет сходящегося баланса присутствия.
===============================================================================
"""
import logging
from typing import Dict, Any, List
import pandas as pd
# Используем правильные имена функций из services/knowledge/service.py
from services.knowledge.service import get_department_synonyms_dict, get_rules
logger = logging.getLogger("SCUD_MERGER")
def merge_scud_and_1c(
df_scud: pd.DataFrame,
df_staff_1c: pd.DataFrame,
df_absences_1c: pd.DataFrame
) -> pd.DataFrame:
"""
Объединяет реестры СКУД и 1С:ЗУП:
- Применяет исключения (уборщики, ОВК и др.).
- Устанавливает короткие аббревиатуры подразделений (КО, ОАН, РУК и др.).
- Привязывает кадровые документы отсутствий из 1С.
"""
# Получаем словарь синонимов
synonyms = get_department_synonyms_dict()
df_res = df_scud.copy()
if "Сотрудник" in df_res.columns:
df_res["fio_clean"] = df_res["Сотрудник"].astype(str).str.strip()
# 1. Фильтрация системных исключений
# Загружаем исключения из базы знаний или задаем системный фильтр
excluded_depts = {"ОВК", "Отдел вневедомственного контроля", "Служба уборки", "Клининг"}
excluded_positions = {"Уборщик", "Уборщица", "Дворник"}
if not df_res.empty:
if "Подразделение" in df_res.columns:
df_res = df_res[~df_res["Подразделение"].astype(str).isin(excluded_depts)]
if "Должность" in df_res.columns:
df_res = df_res[~df_res["Должность"].astype(str).isin(excluded_positions)]
# 2. Трансляция подразделений в короткие аббревиатуры СКУД
if "Подразделение" in df_res.columns:
df_res["Подразделение"] = df_res["Подразделение"].apply(
lambda d: synonyms.get(d, d)
)
# 3. Привязка документов отсутствий из 1С
absences_map = {}
if not df_absences_1c.empty and "Сотрудник" in df_absences_1c.columns and "Причина" in df_absences_1c.columns:
for _, row in df_absences_1c.iterrows():
fio = str(row["Сотрудник"]).strip()
reason = str(row["Причина"]).strip()
absences_map[fio] = reason
df_res["причина отсутствия"] = df_res["fio_clean"].map(absences_map)
return df_res
def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]:
"""
Расчет строго сходящегося баланса присутствия:
A (Итого на работе) + B (Удаленная работа) + C (Официально отсутствуют) + D (Неизвестно) = N (Всего)
"""
total_staff = len(df_merged)
# 1. Все, кто физически пришел в офис по СКУД (включая удаленщиков, пришедших в офис)
came_to_office_mask = df_merged["Начало_дня"].astype(str).str.strip().ne("Нет входа")
working_in_office = df_merged[came_to_office_mask]
working_in_office_count = len(working_in_office)
# 2. Все, кто сегодня НЕ пришел в офис
not_came_mask = ~came_to_office_mask
df_not_came = df_merged[not_came_mask]
# 3. Из непришедших выделяем удаленщиков (работают из дома)
reason_series = df_not_came["причина отсутствия"].astype(str).str.lower()
is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False)
remote_home = df_not_came[is_remote_mask]
remote_home_count = len(remote_home)
# 4. Из оставшихся непришедших выделяем официальные отсутствия (отпуск, больничный, командировка и т.д.)
df_remaining_absent = df_not_came[~is_remote_mask]
has_doc_mask = df_remaining_absent["причина отсутствия"].notna() & \
df_remaining_absent["причина отсутствия"].ne("") & \
df_remaining_absent["причина отсутствия"].ne("nan")
official_absent = df_remaining_absent[has_doc_mask]
official_absent_count = len(official_absent)
# 5. Оставшиеся непришедшие без документов — истинно неизвестные
unknown = df_remaining_absent[~has_doc_mask]
unknown_count = len(unknown)
return {
"total_staff": total_staff,
"working_in_office_count": working_in_office_count,
"remote_home_count": remote_home_count,
"official_absent_count": official_absent_count,
"unknown_count": unknown_count,
"unknown_list": unknown[["fio_clean", "Подразделение", "Должность"]].to_dict(orient="records") if not unknown.empty else []
}
+66
View File
@@ -0,0 +1,66 @@
"""
===============================================================================
FILE: services/scud_etl/pipeline.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / scud_etl
ROLE: Оркестрация выборки снапшотов из SQLite и загрузки кадровых файлов 1С.
===============================================================================
"""
import os
import json
import logging
from typing import Optional
import pandas as pd
from core.connection import get_connection
logger = logging.getLogger("SCUD_PIPELINE")
def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = True) -> pd.DataFrame:
"""
Извлекает срез за дату из SQLite. Для отчета за вчера строго ищет
финальный вечерний срез Y (с зафиксированными выходами за 22:00:00).
"""
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
if prefer_final_y:
cursor.execute("""
SELECT raw_data_json
FROM scud_snapshots
WHERE (snapshot_date = ? OR date_str = ?) AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '22:00%')
ORDER BY id DESC LIMIT 1
""", (date_str, date_str))
row = cursor.fetchone()
if row and row["raw_data_json"]:
data = json.loads(row["raw_data_json"])
return pd.DataFrame(data)
cursor.execute("""
SELECT raw_data_json
FROM scud_snapshots
WHERE snapshot_date = ? OR date_str = ?
ORDER BY id DESC LIMIT 1
""", (date_str, date_str))
row = cursor.fetchone()
if row and row["raw_data_json"]:
data = json.loads(row["raw_data_json"])
return pd.DataFrame(data)
return pd.DataFrame()
def load_1c_files_for_date(date_str: str) -> tuple[pd.DataFrame, pd.DataFrame]:
"""
Загружает файлы Штат_*.xlsx и Отсутствия_*.xlsx за указанную дату из data/1c/.
"""
formatted_date = date_str.replace(".", "_")
staff_file = f"data/1c/Штат_{formatted_date}.xlsx"
absences_file = f"data/1c/Отсутствия_{formatted_date}.xlsx"
df_staff = pd.read_excel(staff_file) if os.path.exists(staff_file) else pd.DataFrame()
df_absences = pd.read_excel(absences_file) if os.path.exists(absences_file) else pd.DataFrame()
return df_staff, df_absences
+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;
"""
View File
+69
View File
@@ -0,0 +1,69 @@
"""
===============================================================================
FILE: services/snapshots/service.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / snapshots
ROLE: Бизнес-логика срезов СКУД (выборка, валидация Y-срезов, удаление).
AI-CONTEXT-ANCHORS:
- ANCHOR[SNAPSHOT_GET_REGISTRY]: Выборка срезов с разметкой защищенных Y-снапшотов.
- ANCHOR[SNAPSHOT_DELETE_SAFE]: Безопасное удаление дневных срезов с защитой итоговых.
===============================================================================
"""
from typing import Dict, Any, Optional, List
from core.connection import get_connection
from core.repositories.scud_repo import get_available_snapshots, delete_snapshot_by_id
# ANCHOR[SNAPSHOT_GET_REGISTRY]
def get_snapshots_registry(date_str: Optional[str] = None) -> Dict[str, Any]:
"""Возвращает реестр снапшотов за дату или за все доступные дни."""
clean_date = date_str.strip() if date_str else ""
rows = get_available_snapshots(date_str=clean_date if clean_date else None)
snapshots = [
{
"snapshot_id": r[0],
"log_date": r[1],
"snapshot_time": r[2],
"record_count": r[3],
"is_final": str(r[0]).startswith("Y")
}
for r in rows
]
return {
"query_date": clean_date or "все",
"snapshots_count": len(snapshots),
"snapshots": snapshots
}
# ANCHOR[SNAPSHOT_DELETE_SAFE]
def delete_snapshots_safely(snapshot_ids: List[str]) -> Dict[str, Any]:
"""
Удаляет выбранные дневные снапшоты.
Итоговые вечерние срезы с префиксом 'Y' гарантированно защищены от удаления.
"""
if not snapshot_ids:
return {"status": "error", "message": "Не указаны ID снапшотов для удаления."}
safe_ids = [str(s).strip() for s in snapshot_ids if s and not str(s).strip().startswith("Y")]
if not safe_ids:
return {"status": "error", "message": "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты."}
with get_connection() as conn:
cursor = conn.cursor()
placeholders = ",".join(["?"] * len(safe_ids))
cursor.execute(f"DELETE FROM scud_logs WHERE snapshot_id IN ({placeholders})", safe_ids)
deleted_count = cursor.rowcount
conn.commit()
return {
"status": "success",
"deleted_count": deleted_count,
"deleted_ids": safe_ids,
"message": f"Успешно удалено снапшотов: {len(safe_ids)} шт."
}
View File
+101
View File
@@ -0,0 +1,101 @@
"""
===============================================================================
FILE: services/tasks/exporter.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / tasks
ROLE: Экспорт бэклога задач в форматированный Markdown файл (ROADMAP).
AI-CONTEXT-ANCHORS:
- ANCHOR[TASK_EXPORT_MARKDOWN]: Построение структуры Markdown с чекбоксами.
===============================================================================
"""
import os
import uuid
import logging
from datetime import datetime
from typing import Dict, Any, Optional, List
from config import OUTPUT_DIR
from .repository import repo_get_tasks
logger = logging.getLogger("TASK_EXPORTER")
# ANCHOR[TASK_EXPORT_MARKDOWN]
def export_tasks_to_markdown(user_id: int, filename: Optional[str] = None, status_filter: Optional[str] = None) -> Dict[str, Any]:
"""Генерирует Markdown-отчет задач с сохранением в output/web/tasks_export/{uuid}/."""
tasks = repo_get_tasks(user_id)
if not tasks:
return {"status": "error", "message": "Список задач пуст, экспорт отменен"}
# 1. Фильтрация задач по статусу
if status_filter and status_filter.upper() != "ALL":
tgt = status_filter.upper()
if tgt in ["COMPLETED", "DONE", "ВЫПОЛНЕННЫЕ"]:
tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["COMPLETED", "DONE"]]
elif tgt in ["IN_PROGRESS", "PROGRESS", "В РАБОТЕ"]:
tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["IN_PROGRESS", "PROGRESS"]]
elif tgt in ["BACKLOG", "PLANNED", "В ПЛАНАХ"]:
tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["BACKLOG", "PLANNED"]]
if not tasks:
return {"status": "error", "message": f"Нет задач с фильтром '{status_filter}' для экспорта"}
target_filename = filename.strip() if (filename and filename.strip()) else "ROADMAP.md"
if not target_filename.endswith(".md"):
target_filename = f"{target_filename}.md"
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
# 2. Группировка по модулям
modules: Dict[str, List[Dict[str, Any]]] = {}
for t in tasks:
mod = t.get("module") or "general"
modules.setdefault(mod, []).append(t)
md_lines = [
"# 🗺️ Дорожная карта задач проекта (ROADMAP)\n",
f"> **Сформировано:** {now_str} | **Всего задач:** {len(tasks)}\n",
"---\n"
]
for mod_name, mod_tasks in sorted(modules.items()):
md_lines.append(f"## Модуль `{mod_name}`\n")
for t in sorted(mod_tasks, key=lambda x: x.get("id", 0)):
status = str(t.get("status", "BACKLOG")).upper()
is_done = status in ["COMPLETED", "DONE"]
is_progress = status in ["IN_PROGRESS", "PROGRESS"]
check_box = "[x]" if is_done else "[ ]"
t_id = t.get("id")
title = t.get("title", "Без названия")
prio = t.get("priority", "MEDIUM")
due = f" *(срок: {t['due_date']})*" if t.get("due_date") else ""
status_tag = " `[В РАБОТЕ]`" if is_progress else (" `[ЗАВЕРШЕНО]`" if is_done else "")
md_lines.append(f"- {check_box} **#{t_id}** [{prio}]{status_tag} {title}{due}")
md_lines.append("\n---\n")
content = "\n".join(md_lines)
# 3. Сохранение файла в изолированную сессионную папку
tool_dir = os.path.join(OUTPUT_DIR, "web", "tasks_export")
os.makedirs(tool_dir, exist_ok=True)
session_token = uuid.uuid4().hex[:8]
session_dir = os.path.join(tool_dir, session_token)
os.makedirs(session_dir, exist_ok=True)
filepath = os.path.join(session_dir, target_filename)
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
return {
"status": "success",
"filename": target_filename,
"filepath": filepath,
"download_url": f"/api/v1/files/download/tasks_export/{session_token}/{target_filename}",
"tasks_count": len(tasks),
"message": f"Отчет успешно сформирован в файл `{target_filename}` (всего задач: {len(tasks)})."
}
+181
View File
@@ -0,0 +1,181 @@
"""
===============================================================================
FILE: services/tasks/repository.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / tasks
ROLE: Низкоуровневые операции к таблице tasks в SQLite (CRUD).
AI-CONTEXT-ANCHORS:
- ANCHOR[TASK_REPO_GET]: Выборка задач с фильтрацией по статусу и пользователю.
- ANCHOR[TASK_REPO_ADD]: Вставка новой задачи со сквозным ID.
- ANCHOR[TASK_REPO_UPDATE]: Обновление реквизитов и статуса задачи.
- ANCHOR[TASK_REPO_DELETE]: Удаление задачи по числовому или строковому ID.
===============================================================================
"""
import re
from typing import List, Dict, Any, Optional
from core.connection import get_connection
def normalize_task_id(task_id_input: str) -> str:
"""Нормализует идентификатор задачи к формату TASK-XX."""
if not task_id_input:
return ""
clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "").replace("#", "")
if clean_id.isdigit():
num = int(clean_id)
return f"TASK-{num:02d}" if num < 100 else f"TASK-{num:03d}"
return f"TASK-{clean_id}"
# ANCHOR[TASK_REPO_GET]
def repo_get_tasks(user_id: int, status: Optional[str] = None) -> List[Dict[str, Any]]:
"""Получает список задач пользователя с опциональной фильтрацией по статусу."""
conn = get_connection(row_factory=True)
cursor = conn.cursor()
if status and status.upper() != "ALL":
target_status = status.upper()
if target_status in ["PROGRESS", "В РАБОТЕ"]:
target_status = "IN_PROGRESS"
elif target_status in ["DONE", "ГОТОВО"]:
target_status = "COMPLETED"
elif target_status in ["PLANNED", "ПЛАНЫ"]:
target_status = "BACKLOG"
cursor.execute("""
SELECT id, task_id, module, title, priority, status, due_date, created_at
FROM tasks
WHERE user_id = ? AND (status = ? OR (status = 'BACKLOG' AND ? = 'PLANNED'))
ORDER BY id DESC
""", (user_id, target_status, target_status))
else:
cursor.execute("""
SELECT id, task_id, module, title, priority, status, due_date, created_at
FROM tasks
WHERE user_id = ?
ORDER BY id DESC
""", (user_id,))
rows = cursor.fetchall()
conn.close()
return [dict(r) for r in rows]
# ANCHOR[TASK_REPO_ADD]
def repo_add_task(
user_id: int,
module: str,
title: str,
priority: str = "MEDIUM",
due_date: Optional[str] = None,
status: str = "BACKLOG"
) -> Dict[str, Any]:
"""Добавляет новую задачу в SQLite с автогенерацией порядкового TASK-ID."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT MAX(id) FROM tasks")
max_id = cursor.fetchone()[0] or 0
new_task_id = f"TASK-{(max_id + 1):02d}"
target_status = status.upper() if status else "BACKLOG"
if target_status in ["PROGRESS", "В РАБОТЕ"]:
target_status = "IN_PROGRESS"
elif target_status in ["DONE", "ГОТОВО"]:
target_status = "COMPLETED"
elif target_status in ["PLANNED", "ПЛАНЫ", "BACKLOG"]:
target_status = "BACKLOG"
cursor.execute("""
INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (new_task_id, module or "general", title.strip(), priority.upper(), target_status, due_date, user_id))
conn.commit()
conn.close()
return {"status": "success", "task_id": new_task_id, "id": max_id + 1}
# ANCHOR[TASK_REPO_UPDATE]
def repo_update_task(
user_id: int,
task_id: str,
title: Optional[str] = None,
priority: Optional[str] = None,
status: Optional[str] = None,
due_date: Optional[str] = None
) -> Dict[str, Any]:
"""Комплексное обновление атрибутов задачи."""
conn = get_connection()
cursor = conn.cursor()
clean_num = re.sub(r'\D', '', str(task_id))
formatted_id = normalize_task_id(task_id)
updates = []
params = []
if title is not None and title.strip():
updates.append("title = ?")
params.append(title.strip())
if priority is not None and priority.strip():
updates.append("priority = ?")
params.append(priority.strip().upper())
if status is not None and status.strip():
target_status = status.strip().upper()
if target_status in ["PROGRESS", "В РАБОТЕ"]:
target_status = "IN_PROGRESS"
elif target_status in ["DONE", "ГОТОВО"]:
target_status = "COMPLETED"
elif target_status in ["PLANNED", "ПЛАНЫ"]:
target_status = "BACKLOG"
updates.append("status = ?")
params.append(target_status)
if due_date is not None:
updates.append("due_date = ?")
params.append(due_date.strip() if due_date.strip() else None)
if not updates:
conn.close()
return {"status": "success", "message": "Нет данных для обновления"}
params.extend([clean_num, formatted_id, f"%{task_id.strip()}", user_id])
sql = f"""
UPDATE tasks
SET {', '.join(updates)}
WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
"""
cursor.execute(sql, params)
rows_affected = cursor.rowcount
conn.commit()
conn.close()
if rows_affected == 0:
return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"}
return {"status": "success", "message": f"Задача #{task_id} успешно обновлена"}
# ANCHOR[TASK_REPO_DELETE]
def repo_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
"""Удаляет задачу по номеру ID."""
conn = get_connection()
cursor = conn.cursor()
clean_num = re.sub(r'\D', '', str(task_id))
formatted_id = normalize_task_id(task_id)
cursor.execute("""
DELETE FROM tasks
WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
""", (clean_num, formatted_id, f"%{task_id.strip()}", user_id))
deleted = cursor.rowcount
conn.commit()
conn.close()
if deleted == 0:
return {"error": f"Задача {task_id} не найдена"}
return {"status": "success", "message": f"Задача #{task_id} удалена"}
+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}'"}
View File
+49
View File
@@ -0,0 +1,49 @@
"""
===============================================================================
FILE: services/text_reporter/service.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / text_reporter
ROLE: Формирование текстовой сводки ИИ-аудитора для вывода в консоль и сохранения в MD.
===============================================================================
"""
from typing import Dict, Any, List
def format_controlling_summary_markdown(
target_date: str,
metrics: Dict[str, Any],
anomalies: List[Dict[str, Any]]
) -> str:
"""Генерирует Markdown-текст ежедневной сводки контроллинга."""
lines = [
f"**Сводка контроллинга СКУД и 1С:ЗУП на {target_date}**\n",
f"- Всего сотрудников по штату: **{metrics['total_staff']}**",
f"- Итого на работе (в офисе): **{metrics['working_in_office_count']}**",
f"- Удаленная работа (из дома): **{metrics['remote_home_count']}**",
f"- Официально отсутствуют: **{metrics['official_absent_count']}**",
f"- Неизвестно (истинно неотмеченные): **{metrics['unknown_count']}** чел.",
f"- Выявлено аномалий/конфликтов реестров: **{len(anomalies)}** шт.\n"
]
# Блок аномалий
lines.append(f"#### 🚨 Выявленные ИИ аномалии и конфликты источников ({len(anomalies)}):")
if anomalies:
for idx, a in enumerate(anomalies, 1):
lines.append(f"{idx}. {a['fio']}: {a['description']}")
else:
lines.append("Конфликтов и аномалий реестров не обнаружено.")
lines.append("")
# Блок неизвестных
unknown_list = metrics.get("unknown_list", [])
lines.append(f"#### ❓ Неизвестные случаи ({len(unknown_list)}):")
if unknown_list:
for idx, u in enumerate(unknown_list, 1):
dept = u.get("Подразделение") or "—"
pos = u.get("Должность") or "—"
lines.append(f"{idx}. {u.get('fio_clean')} — {dept}, {pos}")
else:
lines.append("Все отсутствия подтверждены документами.")
return "\n".join(lines)