refactor: step 2 - extract domain services (tasks, prompts, snapshots, knowledge)
This commit is contained in:
@@ -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
@@ -1,480 +1,35 @@
|
|||||||
import os
|
"""
|
||||||
import sqlite3
|
===============================================================================
|
||||||
import json
|
FILE: core/database.py
|
||||||
import pandas as pd
|
ROLE: Фасад ядра базы данных с полной обратной совместимостью импортов.
|
||||||
from datetime import datetime
|
===============================================================================
|
||||||
from config import DATA_DIR
|
"""
|
||||||
|
|
||||||
DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
|
from core.connection import get_connection, DB_PATH
|
||||||
|
from core.schema import init_all_tables
|
||||||
|
|
||||||
def get_connection():
|
from core.repositories.scud_repo import (
|
||||||
"""Создает подключение к базе данных SQLite с оптимизированными настройками."""
|
has_scud_logs_for_date,
|
||||||
conn = sqlite3.connect(DB_PATH)
|
has_yesterday_final_snapshot,
|
||||||
conn.execute("PRAGMA foreign_keys = ON;")
|
get_or_create_snapshot_id,
|
||||||
conn.execute("PRAGMA journal_mode = WAL;")
|
save_scud_to_db,
|
||||||
return conn
|
get_latest_snapshot_time,
|
||||||
|
load_scud_from_db_by_snapshot,
|
||||||
|
get_available_snapshots,
|
||||||
def init_db():
|
delete_snapshot_by_id,
|
||||||
"""Создает структуру таблиц и индексов в базе данных."""
|
delete_snapshots_by_date
|
||||||
with get_connection() as conn:
|
)
|
||||||
cursor = conn.cursor()
|
|
||||||
|
from core.repositories.zup_repo import (
|
||||||
cursor.execute("""
|
save_staff_to_db,
|
||||||
CREATE TABLE IF NOT EXISTS scud_logs (
|
load_staff_from_db,
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
save_absences_to_db,
|
||||||
log_date TEXT NOT NULL,
|
load_absences_from_db,
|
||||||
fio TEXT NOT NULL,
|
save_anomalies_to_db,
|
||||||
fio_clean TEXT NOT NULL,
|
get_all_rules_from_db,
|
||||||
department TEXT,
|
add_rule_to_db,
|
||||||
position TEXT,
|
get_department_synonyms_dict,
|
||||||
time_in TEXT,
|
add_department_synonym_to_db
|
||||||
first_activity TEXT DEFAULT '—',
|
)
|
||||||
time_out TEXT,
|
|
||||||
time_in_building TEXT,
|
init_db = init_all_tables
|
||||||
is_present INTEGER NOT NULL,
|
|
||||||
anomaly_flag TEXT DEFAULT 'NONE',
|
|
||||||
snapshot_time TEXT DEFAULT NULL,
|
|
||||||
snapshot_id TEXT DEFAULT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
""")
|
|
||||||
|
|
||||||
cursor.execute("PRAGMA table_info(scud_logs);")
|
|
||||||
cols = [col[1] for col in cursor.fetchall()]
|
|
||||||
if 'snapshot_id' not in cols:
|
|
||||||
cursor.execute("ALTER TABLE scud_logs ADD COLUMN snapshot_id TEXT DEFAULT NULL;")
|
|
||||||
|
|
||||||
cursor.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS zup_staff (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
snapshot_date TEXT NOT NULL,
|
|
||||||
fio TEXT NOT NULL,
|
|
||||||
fio_clean TEXT NOT NULL,
|
|
||||||
department TEXT,
|
|
||||||
position TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
""")
|
|
||||||
|
|
||||||
cursor.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS zup_absences (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
absence_date TEXT NOT NULL,
|
|
||||||
fio TEXT NOT NULL,
|
|
||||||
fio_clean TEXT NOT NULL,
|
|
||||||
absence_type TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
""")
|
|
||||||
|
|
||||||
cursor.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS anomalies_history (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
anomaly_date TEXT NOT NULL,
|
|
||||||
fio TEXT NOT NULL,
|
|
||||||
anomaly_type TEXT NOT NULL,
|
|
||||||
details TEXT NOT NULL,
|
|
||||||
human_status TEXT DEFAULT 'Pending',
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
""")
|
|
||||||
|
|
||||||
cursor.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS ai_knowledge_base (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
rule_text TEXT UNIQUE NOT NULL,
|
|
||||||
added_by TEXT DEFAULT 'Human',
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
""")
|
|
||||||
|
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);")
|
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_fio ON scud_logs(fio_clean);")
|
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_snapshot ON scud_logs(snapshot_time);")
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
sync_knowledge_base_to_db()
|
|
||||||
|
|
||||||
def has_scud_logs_for_date(date_str):
|
|
||||||
"""Проверяет, есть ли в базе данные СКУД за указанную дату."""
|
|
||||||
with get_connection() as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("SELECT 1 FROM scud_logs WHERE log_date = ? LIMIT 1", (date_str,))
|
|
||||||
return cursor.fetchone() is not None
|
|
||||||
|
|
||||||
def has_yesterday_final_snapshot(date_str):
|
|
||||||
"""Проверяет, зафиксирован ли уже ИТОГОВЫЙ вчерашний снапшот с индексом Y/22:00."""
|
|
||||||
with get_connection() as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute(
|
|
||||||
"SELECT 1 FROM scud_logs WHERE log_date = ? AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%22:00:00') LIMIT 1",
|
|
||||||
(date_str,)
|
|
||||||
)
|
|
||||||
return cursor.fetchone() is not None
|
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_snapshot_id(snapshot_time, date_str=None, is_yesterday=False):
|
|
||||||
"""
|
|
||||||
Генерирует датированный составной ID снапшота (YYYYMMDD-NNN или YYYYMMDD-NNN).
|
|
||||||
Префикс 'Y' присваивается автоматически, если дата логов (date_str) предшествует дате снятия (snapshot_time).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
dt_snap = datetime.strptime(snapshot_time, "%Y-%m-%d %H:%M:%S").date()
|
|
||||||
date_prefix = dt_snap.strftime("%Y%m%d")
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
dt_snap = datetime.now().date()
|
|
||||||
date_prefix = dt_snap.strftime("%Y%m%d")
|
|
||||||
|
|
||||||
# Автоматическая проверка: если дата логов раньше даты выгрузки — проставляем Y
|
|
||||||
if date_str:
|
|
||||||
try:
|
|
||||||
dt_log = datetime.strptime(date_str, "%d.%m.%Y").date()
|
|
||||||
if dt_log < dt_snap:
|
|
||||||
is_yesterday = True
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
prefix = "Y" if is_yesterday else ""
|
|
||||||
|
|
||||||
with get_connection() as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
if date_str:
|
|
||||||
cursor.execute(
|
|
||||||
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
|
|
||||||
(date_str, snapshot_time)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cursor.execute(
|
|
||||||
"SELECT snapshot_id FROM scud_logs WHERE snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
|
|
||||||
(snapshot_time,)
|
|
||||||
)
|
|
||||||
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if row and row[0]:
|
|
||||||
return row[0]
|
|
||||||
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT snapshot_id
|
|
||||||
FROM scud_logs
|
|
||||||
WHERE snapshot_id LIKE ? OR snapshot_id LIKE ?
|
|
||||||
ORDER BY snapshot_id DESC LIMIT 1
|
|
||||||
""", (f"{date_prefix}-%", f"Y{date_prefix}-%"))
|
|
||||||
|
|
||||||
last_row = cursor.fetchone()
|
|
||||||
next_seq = 1
|
|
||||||
|
|
||||||
if last_row and last_row[0]:
|
|
||||||
parts = last_row[0].replace("Y", "").split("-")
|
|
||||||
if len(parts) > 1 and parts[1].isdigit():
|
|
||||||
next_seq = int(parts[1]) + 1
|
|
||||||
|
|
||||||
return f"{prefix}{date_prefix}-{next_seq:03d}"
|
|
||||||
|
|
||||||
|
|
||||||
def save_scud_to_db(df_scud, date_str, snapshot_time=None, is_yesterday=False):
|
|
||||||
"""Сохраняет логи СКУД с автоматическим присвоением составного snapshot_id."""
|
|
||||||
if df_scud is None or df_scud.empty:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not snapshot_time:
|
|
||||||
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
|
|
||||||
# Передаем date_str в генератор snapshot_id
|
|
||||||
snapshot_id = get_or_create_snapshot_id(snapshot_time, date_str=date_str, is_yesterday=is_yesterday)
|
|
||||||
|
|
||||||
data_to_insert = []
|
|
||||||
for _, r in df_scud.iterrows():
|
|
||||||
data_to_insert.append((
|
|
||||||
date_str,
|
|
||||||
r.get('Сотрудник', r.get('fio_raw', '')),
|
|
||||||
r.get('fio_clean', ''),
|
|
||||||
r.get('Подразделение', ''),
|
|
||||||
r.get('Должность', ''),
|
|
||||||
r.get('Начало_дня', 'Нет входа'),
|
|
||||||
r.get('Первая_активность', '—'),
|
|
||||||
r.get('Конец_дня', 'Нет выхода'),
|
|
||||||
r.get('Находился_в_здании', '00:00'),
|
|
||||||
1 if r.get('Пришел', False) else 0,
|
|
||||||
r.get('anomaly_flag', 'NONE'),
|
|
||||||
snapshot_time,
|
|
||||||
snapshot_id
|
|
||||||
))
|
|
||||||
|
|
||||||
with get_connection() as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? AND snapshot_time = ?", (date_str, snapshot_time))
|
|
||||||
cursor.executemany("""
|
|
||||||
INSERT INTO scud_logs (
|
|
||||||
log_date, fio, fio_clean, department, position,
|
|
||||||
time_in, first_activity, time_out, time_in_building,
|
|
||||||
is_present, anomaly_flag, snapshot_time, snapshot_id
|
|
||||||
)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""", data_to_insert)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def get_latest_snapshot_time(date_str=None):
|
|
||||||
with get_connection() as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
if date_str:
|
|
||||||
cursor.execute("SELECT snapshot_time FROM scud_logs WHERE log_date = ? AND snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1", (date_str,))
|
|
||||||
else:
|
|
||||||
cursor.execute("SELECT snapshot_time FROM scud_logs WHERE snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1")
|
|
||||||
row = cursor.fetchone()
|
|
||||||
return row[0] if row else None
|
|
||||||
|
|
||||||
|
|
||||||
def load_scud_from_db_by_snapshot(date_str, snapshot_param=None):
|
|
||||||
"""
|
|
||||||
Загружает логи СКУД по ID снапшота.
|
|
||||||
Если snapshot_param не задан (или искался прошлый день), приоритетно выбирает
|
|
||||||
финальный вечерний снапшот, начинающийся с буквы 'Y' (срез на 22:00) за дату date_str.
|
|
||||||
"""
|
|
||||||
with get_connection() as conn:
|
|
||||||
df = pd.DataFrame()
|
|
||||||
|
|
||||||
# 1. Если передан конкретный ID снапшота (например, '20260810-001')
|
|
||||||
if snapshot_param:
|
|
||||||
df = pd.read_sql_query(
|
|
||||||
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
|
|
||||||
conn, params=(str(snapshot_param),)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. Если по snapshot_param ничего не найдено ИЛИ snapshot_param=None (запрос за вчерашний день):
|
|
||||||
if df.empty and date_str:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# ⭐️ ПРИОРИТЕТ 1: Ищем срез, начальный символ которого 'Y' (вечерний зафиксированный Y-снапшот)
|
|
||||||
cursor.execute(
|
|
||||||
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY id DESC LIMIT 1",
|
|
||||||
(date_str,)
|
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
|
||||||
|
|
||||||
# ⭐️ ПРИОРИТЕТ 2: Если Y-снапшот не найден, берем самый свежий обычный
|
|
||||||
if not row:
|
|
||||||
cursor.execute(
|
|
||||||
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY id DESC LIMIT 1",
|
|
||||||
(date_str,)
|
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
|
||||||
|
|
||||||
if row and row[0]:
|
|
||||||
target_snap_id = row[0]
|
|
||||||
df = pd.read_sql_query(
|
|
||||||
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
|
|
||||||
conn, params=(target_snap_id,)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Приводим названия колонок к стандарту
|
|
||||||
if not df.empty:
|
|
||||||
rename_map = {
|
|
||||||
'department': 'Подразделение',
|
|
||||||
'position': 'Должность',
|
|
||||||
'fio': 'Сотрудник',
|
|
||||||
'time_in': 'Начало_дня',
|
|
||||||
'first_activity': 'Первая_активность',
|
|
||||||
'time_out': 'Конец_дня',
|
|
||||||
'duration': 'Находился_в_здании',
|
|
||||||
'is_present': 'Пришел'
|
|
||||||
}
|
|
||||||
# Переименовываем имеющиеся колонки
|
|
||||||
df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
|
|
||||||
|
|
||||||
# Заполняем недостающие колонки значениями по умолчанию, если их не было в БД
|
|
||||||
required_cols = ['Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']
|
|
||||||
for col in required_cols:
|
|
||||||
if col not in df.columns:
|
|
||||||
if col == 'Пришел':
|
|
||||||
df[col] = False
|
|
||||||
else:
|
|
||||||
df[col] = '—'
|
|
||||||
|
|
||||||
# Приводим тип флага Пришел к bool
|
|
||||||
if 'Пришел' in df.columns:
|
|
||||||
df['Пришел'] = df['Пришел'].astype(bool)
|
|
||||||
|
|
||||||
return df
|
|
||||||
|
|
||||||
def get_available_snapshots(date_str=None):
|
|
||||||
"""
|
|
||||||
Возвращает список снапшотов.
|
|
||||||
Сортировка по времени snapshot_time DESC.
|
|
||||||
"""
|
|
||||||
with get_connection() as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
if date_str:
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT
|
|
||||||
snapshot_id,
|
|
||||||
log_date,
|
|
||||||
snapshot_time,
|
|
||||||
COUNT(*) as cnt
|
|
||||||
FROM scud_logs
|
|
||||||
WHERE log_date = ? AND snapshot_time IS NOT NULL
|
|
||||||
GROUP BY snapshot_id, log_date, snapshot_time
|
|
||||||
ORDER BY snapshot_time DESC
|
|
||||||
""", (date_str,))
|
|
||||||
else:
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT
|
|
||||||
snapshot_id,
|
|
||||||
log_date,
|
|
||||||
snapshot_time,
|
|
||||||
COUNT(*) as cnt
|
|
||||||
FROM scud_logs
|
|
||||||
WHERE snapshot_time IS NOT NULL
|
|
||||||
GROUP BY snapshot_id, log_date, snapshot_time
|
|
||||||
ORDER BY snapshot_time DESC
|
|
||||||
""")
|
|
||||||
return cursor.fetchall()
|
|
||||||
|
|
||||||
|
|
||||||
def save_staff_to_db(df_staff, date_str):
|
|
||||||
if df_staff is None or df_staff.empty:
|
|
||||||
return
|
|
||||||
data_to_insert = [
|
|
||||||
(date_str, r.get('ФИО', ''), r.get('fio_clean', ''), r.get('Подразделение', ''), r.get('Должность', ''))
|
|
||||||
for _, r in df_staff.iterrows()
|
|
||||||
]
|
|
||||||
with get_connection() as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("DELETE FROM zup_staff WHERE snapshot_date = ?", (date_str,))
|
|
||||||
cursor.executemany("INSERT INTO zup_staff (snapshot_date, fio, fio_clean, department, position) VALUES (?, ?, ?, ?, ?)", data_to_insert)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def save_absences_to_db(df_absent, date_str):
|
|
||||||
if df_absent is None or df_absent.empty:
|
|
||||||
return
|
|
||||||
data_to_insert = [
|
|
||||||
(date_str, r.get('ФИО', r.get('fio_clean', '')), r.get('fio_clean', ''), r.get('Вид_отсутствия', ''))
|
|
||||||
for _, r in df_absent.iterrows()
|
|
||||||
]
|
|
||||||
with get_connection() as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("DELETE FROM zup_absences WHERE absence_date = ?", (date_str,))
|
|
||||||
cursor.executemany("INSERT INTO zup_absences (absence_date, fio, fio_clean, absence_type) VALUES (?, ?, ?, ?)", data_to_insert)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def save_anomalies_to_db(anomalies_list, date_str):
|
|
||||||
if not anomalies_list:
|
|
||||||
return
|
|
||||||
data_to_insert = [
|
|
||||||
(date_str, a.get('fio', ''), a.get('type', ''), a.get('details', ''))
|
|
||||||
for a in anomalies_list
|
|
||||||
]
|
|
||||||
with get_connection() as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("DELETE FROM anomalies_history WHERE anomaly_date = ?", (date_str,))
|
|
||||||
cursor.executemany("INSERT INTO anomalies_history (anomaly_date, fio, anomaly_type, details) VALUES (?, ?, ?, ?)", data_to_insert)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def get_all_rules_from_db():
|
|
||||||
with get_connection() as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("SELECT rule_text FROM ai_knowledge_base")
|
|
||||||
return [r[0] for r in cursor.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
def add_rule_to_db(rule_text, added_by="Human"):
|
|
||||||
if not rule_text or not rule_text.strip():
|
|
||||||
return
|
|
||||||
with get_connection() as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
try:
|
|
||||||
cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)", (rule_text.strip(), added_by))
|
|
||||||
conn.commit()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[⚠️] Ошибка записи правила в БД: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def sync_knowledge_base_to_db():
|
|
||||||
pass
|
|
||||||
|
|
||||||
def delete_snapshot_by_id(snapshot_id: str):
|
|
||||||
"""Удаляет конкретный снапшот из таблицы scud_logs по его ID."""
|
|
||||||
with get_connection() as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
|
|
||||||
deleted_count = cursor.rowcount
|
|
||||||
conn.commit()
|
|
||||||
print(f"[✓] Удален снапшот [{snapshot_id}]. Удалено строк: {deleted_count}")
|
|
||||||
return deleted_count
|
|
||||||
|
|
||||||
def delete_snapshots_by_date(date_str: str):
|
|
||||||
"""Удаляет все снапшоты за указанную дату (например, '04.08.2026')."""
|
|
||||||
with get_connection() as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
# Удаляем по log_date или по дате внутри snapshot_id
|
|
||||||
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%"))
|
|
||||||
deleted_count = cursor.rowcount
|
|
||||||
conn.commit()
|
|
||||||
print(f"[✓] Удалены все снапшоты за дату [{date_str}]. Удалено строк: {deleted_count}")
|
|
||||||
return deleted_count
|
|
||||||
def init_department_synonyms_db():
|
|
||||||
"""Создает таблицу синонимов отделов в БД SQLite."""
|
|
||||||
with get_connection() as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS department_synonyms (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
short_name TEXT UNIQUE NOT NULL,
|
|
||||||
full_name TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
""")
|
|
||||||
# По умолчанию добавляем ОВК -> Отдел внутреннего контроля
|
|
||||||
cursor.execute("""
|
|
||||||
INSERT OR IGNORE INTO department_synonyms (short_name, full_name)
|
|
||||||
VALUES ('овк', 'отдел внутреннего контроля')
|
|
||||||
""")
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def get_department_synonyms_dict():
|
|
||||||
"""Возвращает словарь всех изученных синонимов {short_name: full_name}."""
|
|
||||||
init_department_synonyms_db()
|
|
||||||
with get_connection() as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("SELECT LOWER(short_name), LOWER(full_name) FROM department_synonyms")
|
|
||||||
return {row[0]: row[1] for row in cursor.fetchall()}
|
|
||||||
|
|
||||||
|
|
||||||
def add_department_synonym_to_db(short_name, full_name):
|
|
||||||
"""Сохраняет новую пару синонимов отдела в базу SQLite."""
|
|
||||||
init_department_synonyms_db()
|
|
||||||
with get_connection() as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute(
|
|
||||||
"INSERT OR REPLACE INTO department_synonyms (short_name, full_name) VALUES (?, ?)",
|
|
||||||
(short_name.strip().lower(), full_name.strip().lower())
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
print(f"[✓] В базу SQLite добавлен новый синоним отдела: '{short_name}' ⟷ '{full_name}'")
|
|
||||||
|
|
||||||
def load_staff_from_db(date_str):
|
|
||||||
"""Загружает Штатное расписание 1С из базы SQLite за указанную дату."""
|
|
||||||
with get_connection() as conn:
|
|
||||||
df = pd.read_sql_query(
|
|
||||||
"SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?",
|
|
||||||
conn, params=(date_str,)
|
|
||||||
)
|
|
||||||
return df if not df.empty else None
|
|
||||||
|
|
||||||
def load_absences_from_db(date_str):
|
|
||||||
"""Загружает документальные отсутствия 1С из базы SQLite за указанную дату."""
|
|
||||||
with get_connection() as conn:
|
|
||||||
df = pd.read_sql_query(
|
|
||||||
"SELECT fio as 'ФИО', fio_clean, absence_type as 'Вид_отсутствия' FROM zup_absences WHERE absence_date = ?",
|
|
||||||
conn, params=(date_str,)
|
|
||||||
)
|
|
||||||
return df if not df.empty else None
|
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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.
@@ -3,42 +3,61 @@
|
|||||||
FILE: modules/web_api/llm/db_tools.py
|
FILE: modules/web_api/llm/db_tools.py
|
||||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||||
MODULE: web_api / llm
|
MODULE: web_api / llm
|
||||||
ROLE: Фасадная точка доступа ко всем функциям базы данных SQLite.
|
ROLE: Фасадная точка доступа к доменным сервисам (Domain Facade).
|
||||||
|
|
||||||
|
AI-CONTEXT-ANCHORS:
|
||||||
|
- ANCHOR[FACADE_EXPORTS]: Экспорт методов предметных сервисов для LLM и API.
|
||||||
===============================================================================
|
===============================================================================
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .db.connection import DB_PATH, get_db_connection
|
# ANCHOR[FACADE_EXPORTS]
|
||||||
from .db.db_prompts import (
|
from core.connection import DB_PATH, get_connection as get_db_connection
|
||||||
db_get_active_system_prompt,
|
|
||||||
db_add_system_prompt,
|
# Домен: Задачи
|
||||||
db_apply_prompt_node_action,
|
from services.tasks.service import (
|
||||||
db_get_tool_action,
|
get_tasks as db_get_tasks,
|
||||||
db_set_session_state,
|
add_task as db_add_task,
|
||||||
db_get_session_state,
|
update_task_details as db_update_task_details,
|
||||||
db_clear_session_state,
|
delete_task as db_delete_task,
|
||||||
db_get_rules,
|
execute_task_action as db_tasks_edit
|
||||||
db_get_stats,
|
|
||||||
db_get_anomalies,
|
|
||||||
db_get_reference
|
|
||||||
)
|
)
|
||||||
|
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 as db_add_system_prompt,
|
||||||
|
create_prompt_preview
|
||||||
|
)
|
||||||
|
|
||||||
|
# Домен: Снапшоты СКУД
|
||||||
|
from services.snapshots.service import (
|
||||||
|
get_snapshots_registry as db_get_snapshots,
|
||||||
|
delete_snapshots_safely as db_delete_snapshots
|
||||||
|
)
|
||||||
|
|
||||||
|
# Домен: База знаний
|
||||||
|
from services.knowledge.service import (
|
||||||
|
get_rules as db_get_rules,
|
||||||
|
add_rule as db_add_rule
|
||||||
|
)
|
||||||
|
|
||||||
|
# Чат, сессии, статистика
|
||||||
from .db.db_chat import (
|
from .db.db_chat import (
|
||||||
db_save_chat_message,
|
db_save_chat_message,
|
||||||
db_get_chat_history,
|
db_get_chat_history,
|
||||||
db_purge_ephemeral_messages,
|
db_purge_ephemeral_messages,
|
||||||
db_clear_chat_history
|
db_clear_chat_history
|
||||||
)
|
)
|
||||||
from .db.db_tasks import (
|
from .db.db_prompts import (
|
||||||
normalize_task_id,
|
db_get_tool_action,
|
||||||
db_get_tasks,
|
db_set_session_state,
|
||||||
db_add_task,
|
db_get_session_state,
|
||||||
db_update_task_status,
|
db_clear_session_state,
|
||||||
db_update_task_details,
|
db_get_stats,
|
||||||
db_delete_task,
|
db_get_anomalies,
|
||||||
db_export_tasks_markdown,
|
db_get_reference
|
||||||
db_tasks_edit
|
|
||||||
)
|
|
||||||
from .db.db_snapshots import (
|
|
||||||
db_get_snapshots,
|
|
||||||
db_delete_snapshots
|
|
||||||
)
|
)
|
||||||
from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time
|
from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time
|
||||||
+30
-11
@@ -151,7 +151,7 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
|
|||||||
if not unrecognized_scud_fios or not staff_fios:
|
if not unrecognized_scud_fios or not staff_fios:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# 🛡 Исключаем точные совпадения (чтобы ИИ не совершал ложных замен)
|
# Исключаем точные совпадения
|
||||||
staff_fios_set = set(staff_fios)
|
staff_fios_set = set(staff_fios)
|
||||||
real_unrecognized = [f for f in unrecognized_scud_fios if f not in staff_fios_set]
|
real_unrecognized = [f for f in unrecognized_scud_fios if f not in staff_fios_set]
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
|
|||||||
prompt = f"""
|
prompt = f"""
|
||||||
Ты — кадровый аудитор безопасности СКУД.
|
Ты — кадровый аудитор безопасности СКУД.
|
||||||
{rules_context}
|
{rules_context}
|
||||||
В СКУД записаны неопознанные ФИО: {json.dumps(unrecognized_scud_fios, ensure_ascii=False)}
|
В СКУД записаны неопознанные ФИО: {json.dumps(real_unrecognized, ensure_ascii=False)}
|
||||||
В официальном Штатном расписании 1С записаны ЭТАЛОНЫ: {json.dumps(staff_fios, ensure_ascii=False)}
|
В официальном Штатном расписании 1С записаны ЭТАЛОНЫ: {json.dumps(staff_fios, ensure_ascii=False)}
|
||||||
|
|
||||||
СТРОГИЕ ПРАВИЛА:
|
СТРОГИЕ ПРАВИЛА:
|
||||||
@@ -173,40 +173,59 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
|
|||||||
4. В поле "warning" опиши обнаруженную опечатку.
|
4. В поле "warning" опиши обнаруженную опечатку.
|
||||||
5. Запрещено выдумывать опечатки и объединять разных людей/однофамильцев!
|
5. Запрещено выдумывать опечатки и объединять разных людей/однофамильцев!
|
||||||
|
|
||||||
ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ JSON!
|
ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ ВАЛИДНОГО JSON:
|
||||||
|
|
||||||
Формат ответа:
|
|
||||||
{{
|
{{
|
||||||
"verified_matches": [
|
"verified_matches": [
|
||||||
{{
|
{{
|
||||||
"scud_fio": "ФИО из СКУД",
|
"scud_fio": "ФИО из СКУД",
|
||||||
"staff_fio": "эталон ФИО из Штат 1С",
|
"staff_fio": "эталон ФИО из Штат 1С",
|
||||||
"warning": "Описание опечатки в СКУД или 'Точное совпадение (без опечаток)'"
|
"warning": "Описание опечатки в СКУД"
|
||||||
}}
|
}}
|
||||||
]
|
]
|
||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
raw_response = ask_ollama(
|
raw_response = ask_ollama(
|
||||||
prompt,
|
prompt,
|
||||||
system_prompt="Ты — JSON API. Выдавай ТОЛЬКО валидный JSON без markdown-разметки."
|
system_prompt="Ты — строгий JSON API генератор. Отвечай только валидным JSON объектом без пояснительного текста."
|
||||||
)
|
)
|
||||||
|
|
||||||
mapping = {}
|
mapping = {}
|
||||||
|
if not raw_response:
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
# Фаза 1: Попытка прямого разбора с санитарной очисткой
|
||||||
try:
|
try:
|
||||||
match = re.search(r'\{.*\}', raw_response, re.DOTALL)
|
match = re.search(r'\{.*\}', raw_response, re.DOTALL)
|
||||||
if match:
|
if match:
|
||||||
json_str = match.group(0)
|
json_str = match.group(0)
|
||||||
json_str = re.sub(r'\}\s*[^}\]]*$', '}', json_str)
|
# Убираем висячие запятые: {"a": 1,} -> {"a": 1}
|
||||||
|
json_str = re.sub(r',\s*([\}\]])', r'\1', json_str)
|
||||||
|
# Заменяем одинарные кавычки в ключах/значениях на двойные при необходимости
|
||||||
|
json_str = re.sub(r"(?<=\{|\,)\s*'([^']+)'\s*:", r'"\1":', json_str)
|
||||||
|
|
||||||
data = json.loads(json_str)
|
data = json.loads(json_str)
|
||||||
for item in data.get("verified_matches", []):
|
for item in data.get("verified_matches", []):
|
||||||
scud_f = item.get("scud_fio")
|
scud_f = item.get("scud_fio")
|
||||||
staff_f = item.get("staff_fio")
|
staff_f = item.get("staff_fio")
|
||||||
warn = item.get("warning", "Точное совпадение (без опечаток)")
|
warn = item.get("warning", "Точное совпадение (без опечаток)")
|
||||||
if scud_f and staff_f:
|
if scud_f and staff_f and staff_f in staff_fios_set:
|
||||||
mapping[scud_f] = {"staff_fio": staff_f, "warning": warn}
|
mapping[scud_f] = {"staff_fio": staff_f, "warning": warn}
|
||||||
|
return mapping
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Фаза 2: Резервный Regex-парсер (если JSON синтаксически сломан, но пары ключ-значение есть)
|
||||||
|
try:
|
||||||
|
pattern = r'["\']scud_fio["\']\s*:\s*["\']([^"\']+)["\'].*?["\']staff_fio["\']\s*:\s*["\']([^"\']+)["\']'
|
||||||
|
matches = re.findall(pattern, raw_response, re.DOTALL)
|
||||||
|
for scud_f, staff_f in matches:
|
||||||
|
scud_clean = scud_f.strip()
|
||||||
|
staff_clean = staff_f.strip()
|
||||||
|
if staff_clean in staff_fios_set:
|
||||||
|
mapping[scud_clean] = {"staff_fio": staff_clean, "warning": "Восстановлено парсером опечаток"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[!] Ошибка разбора JSON от ИИ при сверке опечаток: {e}")
|
print(f"[!] Ошибка резервного парсинга опечаток: {e}")
|
||||||
|
|
||||||
return mapping
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""
|
||||||
|
===============================================================================
|
||||||
|
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
|
||||||
|
from core.connection import get_connection
|
||||||
|
|
||||||
|
|
||||||
|
# ANCHOR[PROMPT_DIFF_BUILDER]
|
||||||
|
def build_prompt_diff(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent") -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Возвращает кортеж (merged_draft_text, diff_html_for_ui).
|
||||||
|
"""
|
||||||
|
act = (action or "ADD").upper()
|
||||||
|
|
||||||
|
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 != (section_id, item_id)} if act == "DELETE" else dict(nodes_dict)
|
||||||
|
if act != "DELETE":
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Формируем HTML Diff
|
||||||
|
diff_lines = []
|
||||||
|
curr_sec = None
|
||||||
|
display_nodes = dict(nodes_dict)
|
||||||
|
if act != "DELETE":
|
||||||
|
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 == section_id and i_id == item_id:
|
||||||
|
if act == "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>'
|
||||||
|
else:
|
||||||
|
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)
|
||||||
|
|
||||||
|
diff_html = "\n".join(diff_lines)
|
||||||
|
return merged_prompt, diff_html
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
===============================================================================
|
||||||
|
FILE: services/prompts/service.py
|
||||||
|
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||||
|
MODULE: services / prompts
|
||||||
|
ROLE: Единый доменный сервис управления системным промптом.
|
||||||
|
===============================================================================
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Tuple, Dict, Any
|
||||||
|
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) -> None:
|
||||||
|
"""Сохранить полный черновик промпта."""
|
||||||
|
repo_save_full_prompt(draft_text)
|
||||||
|
|
||||||
|
|
||||||
|
def create_prompt_preview(action: str, section_id: int, item_id: int, content: str = "") -> Tuple[str, str, str]:
|
||||||
|
"""
|
||||||
|
Формирует черновик и diff.
|
||||||
|
Возвращает (merged_draft, diff_html, baseline_prompt).
|
||||||
|
"""
|
||||||
|
baseline = repo_get_active_prompt()
|
||||||
|
draft, diff_html = build_prompt_diff(action, section_id, item_id, content)
|
||||||
|
return draft, diff_html, baseline
|
||||||
@@ -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)})."
|
||||||
|
}
|
||||||
@@ -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} удалена"}
|
||||||
@@ -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}'"}
|
||||||
Reference in New Issue
Block a user