diff --git a/data/scud_orion_ai.db b/data/scud_orion_ai.db index b24e2af..7900e5b 100644 Binary files a/data/scud_orion_ai.db and b/data/scud_orion_ai.db differ diff --git a/data/scud_orion_ai.db-shm b/data/scud_orion_ai.db-shm index 5ca3d3a..ed9a005 100644 Binary files a/data/scud_orion_ai.db-shm and b/data/scud_orion_ai.db-shm differ diff --git a/data/scud_orion_ai.db-wal b/data/scud_orion_ai.db-wal index ebf0535..9eb5ef3 100644 Binary files a/data/scud_orion_ai.db-wal and b/data/scud_orion_ai.db-wal differ diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..e1ef71a --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,54 @@ +# 📋 История изменений (CHANGELOG) + +Все ключевые изменения архитектуры, инструментов и модулей проекта SCUD Orion AI фиксируются в данном файле[cite: 6]. + +--- + +## [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` по сессиям с визуальным отображением флагов `[ЭФЕМЕРНОЕ]`[cite: 6]. +* **`ROADMAP.md`**: Сформирована актуальная дорожная карта с планом отказа от регулярных выражений в пользу нативного Function Calling[cite: 6]. + +### 🔧 Изменено +* **`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`)[cite: 6]. +* **`db_prompt_node_edit`**: Добавлен инструмент точечного добавления, изменения и удаления пунктов системного промпта[cite: 6]. +* **Инлайн-редактор**: Добавлен визуальный diff-просмотр изменений с подсветкой и кнопкой `[✏️ Редактировать]`[cite: 6]. + +### 🔧 Изменено +* **`modules/web_api/llm/agent.py`**: Генерация активного системного промпта переведена на динамическую сборку из реляционных узлов SQLite[cite: 6]. +* **`modules/web_api/llm/core/fast_path.py`**: Обработка кнопок «Подтвердить» и «Отменить» переведена на атомарную запись узлов в базу данных[cite: 6]. + +--- + +## [2026-08-10] — Механизм эфемерных сообщений и очистки диалога + +### ✨ Добавлено +* **Эфемерные сообщения (`is_ephemeral`)**: Маркировка временных сервисных ответов и черновиков[cite: 6]. +* **`db_purge_ephemeral_messages`**: Механизм физической зачистки временных записей из SQLite при завершении операций[cite: 6]. +* **Интерактивные виджеты**: Поддержка карточек задач (`TASK_INTERACTIVE_CARD`) с кнопками быстрого действия[cite: 6]. \ No newline at end of file diff --git a/docs/PROJECT BRAIN_ SCUD Orion AI & Context API (Master Manifesto v5.0).md b/docs/PROJECT BRAIN_ SCUD Orion AI & Context API (Master Manifesto v5.0).md new file mode 100644 index 0000000..f865bd2 --- /dev/null +++ b/docs/PROJECT BRAIN_ SCUD Orion AI & Context API (Master Manifesto v5.0).md @@ -0,0 +1,185 @@ +# **PROJECT BRAIN: SCUD Orion AI & Context API (Master Technical & Architectural Manifesto)** + +**Версия документа:** 5.0 (Ultimate Developer & LLM Context Engine) +**Назначение:** Исчерпывающий контекстный дамп проекта для немедленного продолжения разработки в любой передовой LLM без потери исторического контекста, с полным пониманием бизнес-логики, схем баз данных, подводных камней и нерешенных задач. + +## --- + +**1\. Архитектурный паспорт и инфраструктурный профиль** + +| Компонент | Параметры и стек | +| :---- | :---- | +| **Среда и ОС** | Linux Docker Home (Debian/Ubuntu), Python 3.11/3.12 в виртуальном окружении venv, Git/Gitea. Разработка ведется через VS Code Remote-SSH. | +| **Локальная СУБД проекта** | SQLite (data/scud\_orion\_ai.db), режим PRAGMA journal\_mode \= WAL, PRAGMA synchronous \= NORMAL, PRAGMA foreign\_keys \= ON. | +| **СКУД Источник (MSSQL)** | MS SQL Server 172.16.31.221\\SQL (база Orion-14.01.21-1), драйвер ODBC Driver 18 for SQL Server (TrustServerCertificate=yes, Encrypt=no). Доступ через pyodbc. | +| **1С:ЗУП 3.1 Источник (MSSQL)** | MS SQL Server ACCOUNT-01 (база ZUP30, пользователь scud\_reader). Прямое чтение таблиц dbo.\_Reference299 (сотрудники) и dbo.\_InfoRg16921 (состояния отсутствий). Резервная Samba-шара: /mnt/scud\_share. | +| **Локальный контур LLM** | Ollama на http://192.168.11.3:11434 (GPU NVIDIA RTX 5070 Ti 16GB). Модель qwen2.5:14b (основной агент, Function Calling, анализ аномалий), qwen2.5vl:7b-q8\_0 (OCR документов/картинок). | +| **Web API & Фронтенд** | FastAPI (Uvicorn), JWT (HS256, 30 дней), Passlib (bcrypt). Фронтенд: Single Page Application (HTML5, Tailwind CSS, Vanilla JS) с Generative UI виджетами. | + +## --- + +**2\. Энциклопедия подводных камней, инцидентов и принятых инженерных решений** + +### **2.1. Галлюцинации LLM и переход к парадигме Python-Driven Diff** + +> * **Проблема:** Попытка передать списки в 300 ФИО напрямую в промпт модели с командой «сравни списки» приводила к потере контекста, выдуманным опечаткам (*«ЛЕНМОРНИИПРОЕКТ ОЭС Варламова Мария»*) и плейсхолдерам ('Васильев Л.И.', ...). +> * **Решение:** Утверждена строгая гибридная схема: + 1. **Детерминированный слой (Python / Pandas / SQL):** читает файлы, нормализует строки, агрегирует совмещения и мгновенно находит 95%+ точных соответствий. + 2. **Интеллектуальный слой (Qwen 2.5:14b):** вызывается *только на спорный несовпавший остаток* (1–5 записей) в виде строгого JSON-интерфейса Entity Resolution. + +### **2.2. Символьная нормализация и кадровые совмещения (Кейс Балаевой О.Г.)** + +> * **Проблема символов:** Латинские омоглифы (буквы клавиатуры a, c, e, o, p, x, y вместо русских), разделители 1С в скобках ((осн.), (совм.), (внешн.)), неразрывные пробелы \\xa0, буква ё и случайный ввод цифры 0 вместо буквы О. +> * **Решение (normalize\_fio):** Очистка скобок через регулярное выражение re.sub(r'\\(.\*?\\)', '', fio), замена латиницы через str.maketrans('aceopxyABCEHKMOPTX', 'асеорхуАВСЕНКМОРТХ'), замена ё-\>е и удаление лишних пробелов. +> * **Коллизия 270 vs 271:** В СКУД 270 физических лиц (один пропуск на человека), в 1С — 271 строка. Сотрудник **Балаева Ольга Геннадьевна** числится на двух ставках в разных отделах (*Руководитель управления персоналом* и *Руководитель режимно-секретного отдела*). +> * **Решение:** Внедрена группировка датафрейма 1С по fio\_clean с агрегацией должностей и подразделений через разделитель " / ". + +### **2.3. Защита от коллизий полных тёзок и ложных связок однофамильцев (Кейс Семеновых)** + +> * **Инцидент:** Неограниченная строгими правилами LLM связала двух разных сотрудниц — *«Семенову Юлию Борисовну»* (работает) и *«Семенову Дину Дмитриевну»* (в отпуске), посчитав разные имена опечаткой из\-за одинаковой фамилии. В результате Юлии Борисовне ошибочно был приписан статус отпуска. +> * **Двухконтурный барьер:** + 1. **В промпте:** Запрет объединения однофамильцев с разными именами/отчествами. + 2. **В Python:** Проверка совпадения первых букв имени и отчества. При несовпадении сопоставление ИИ аннулируется с логированием ⚠️ \[Блокировка фейка\]. + +### **2.4. Вынесение исключений в exceptions.json и синонимы отделов** + +> * Служебный персонал (отдел «ЭТО», уборщики производственных и служебных помещений, клинеры, дворники, гардеробщики) вынесен в exceptions.json. +> * Для сопоставления кратких аббревиатур СКУД и полных названий отделов 1С (*«ОВК» ⟷ «Отдел внутреннего контроля»*) создана таблица department\_synonyms с автоматическим самообучением при работе человека (Human-in-the-Loop). + +### **2.5. Разгадка феномена «исчезающих неизвестных» и аппаратный сбой СКУД 04.08.2026** + +> * **Аномалия:** Утром 04.08.2026 на главном КПП зависли контроллеры входа. У 119 сотрудников не записалось утреннее событие (time\_in \= 'Нет входа'). Из них 57 человек были в отпусках/командировках по 1С. Оставшиеся 74 человека были на работе без утренней отметки. Однако вечерняя сводка показала всего 4–7 «неизвестных». +> * **Причина:** Скрипт агрегации видел дневные перемещения (обед, внутренние двери, туалет, вечерний выход) и автоматически проставлял Статус \= 'Присутствовал' и is\_present \= 1, несмотря на то, что колонка Начало\_дня оставалась 'Нет входа'. В итоге 70 человек тихо ушли в «Итого на работе», а системный сбой оборудования был замаскирован. +> * **Архитектурный фикс:** + 1. Введена колонка **first\_activity («Первая активность»)** — вычисление MIN(TimeVal) AS FirstRawEvent по таблице pLogData без учета направления двери. + 2. Если time\_in \== 'Нет входа', но first\_activity \!= '—', записи ставится anomaly\_flag \= 'ANOMALY\_NO\_IN\_HAS\_ACTIVITY', а is\_present принудительно сбрасывается в 0\. + 3. Внедрен модуль analyze\_scud\_mass\_failure\_ai(): если доля таких аномалий превышает 5% смены (\>10 чел.), ИИ генерирует системный алерт о массовом сбое контроллеров КПП. +> * **Кейс Равина В.Э.:** Главный специалист официально находился в основном отпуске по 1С, утреннего входа на КПП не было, но зафиксирована активность в 14:11:06 и вечерний выход в 17:18:38. Система зарегистрировала истинную кадровую аномалию (физическое присутствие при официальном отпуске). + +### **2.6. Система сквозных датированных снапшотов (срезов)** + +> * **Формат ID:** \[Y\]YYYYMMDD-NNN (например, Y20260804-001 для вчерашнего зафиксированного вечернего среза на 22:00 и 20260805-002 для дневного). +> * **Оптимизация:** Функция has\_yesterday\_final\_snapshot() проверяет наличие вчерашнего среза в SQLite: если он есть, повторный тяжелый запрос к MS SQL Орион пропускается. +> * **CLI-управление:** Поддержка запуска отчетов по любому историческому снапшоту через ключ \--snapshot \[ID\] и пропуск выгрузки через \--skip-export. + +### **2.7. Web API, Generative UI и управление жизненным циклом сессий (Topic Drift & Ephemeral Buffer)** + +> * **Проблема засорения контекста LLM:** Вывод объемных списков задач, системных промптов и таблиц срезов забивал окно диалога, вызывая галлюцинации и «залипание» модели на старых данных. +> * **Решение:** + * В chat\_messages введен флаг is\_ephemeral \= 1 для временных сообщений. + * Двухфазные сессии (PROMPT\_PREVIEW → PROMPT\_FOLLOWUP) и детерминированный счетчик idle\_turns. + * **Context Guard:** На 3-м шаге отвлечения оператора на сторонние темы система отвечает на вопрос и выводит интерактивное напоминание с кнопками \[Завершить настройку\] / \[Показать промпт\]. При idle\_turns \> 3 происходит автоочистка сессии вызовом db\_purge\_ephemeral\_messages(). + * **Fast-Path:** Прямой перехват команд подтверждения («да», «подтверждаю») и выхода («нет, спасибо») без обращения к LLM (ответ за 0.05 сек). + * **Декларативный реестр (tool\_action\_registry):** Хранение шаблонов ответов, кнопок и настроек эфемерности в SQLite. +> * **Generative UI (Задачи):** Инструмент db\_get\_tasks возвращает TASK\_INTERACTIVE\_CARD, рендерящий на клиенте живой виджет задач с табами (Все / В работе / В планах / Завершенные), чекбоксами и inline-созданием задач через REST API. + +## --- + +**3\. Полная схема базы данных SQLite (data/scud\_orion\_ai.db)** + +| Таблица | Назначение и бизнес-логика | Структура полей | +| :---- | :---- | :---- | +| **scud\_logs** | Логи проходов СКУД, расчетное время в здании и снапшоты. | id INTEGER PK AUTOINCREMENT, log\_date TEXT, fio TEXT, fio\_clean TEXT, department TEXT, position TEXT, time\_in TEXT, first\_activity TEXT DEFAULT '—', time\_out TEXT, time\_in\_building TEXT, is\_present INTEGER, anomaly\_flag TEXT DEFAULT 'NONE', snapshot\_time TEXT, snapshot\_id TEXT, created\_at TIMESTAMP | +| **zup\_staff** | Штатное расписание 1С:ЗУП на даты срезов. | id INTEGER PK AUTOINCREMENT, snapshot\_date TEXT, fio TEXT, fio\_clean TEXT, department TEXT, position TEXT, created\_at TIMESTAMP | +| **zup\_absences** | Документы отклонений 1С (отпуска, больничные, командировки, декреты). | id INTEGER PK AUTOINCREMENT, absence\_date TEXT, fio TEXT, fio\_clean TEXT, absence\_type TEXT, created\_at TIMESTAMP | +| **anomalies\_history** | Журнал выявленных кадровых и аппаратных аномалий. | id INTEGER PK AUTOINCREMENT, anomaly\_date TEXT, fio TEXT, anomaly\_type TEXT, details TEXT, human\_status TEXT DEFAULT 'Pending', created\_at TIMESTAMP | +| **ai\_knowledge\_base** | Глобальные правила арбитража и приоритеты компании для ИИ. | id INTEGER PK AUTOINCREMENT, rule\_text TEXT UNIQUE, added\_by TEXT DEFAULT 'Human', created\_at TIMESTAMP | +| **system\_prompts** | Версионные системные промпты агента (активный по is\_active=1). | id INTEGER PK AUTOINCREMENT, name TEXT, prompt\_text TEXT, is\_active INTEGER DEFAULT 1, updated\_at TIMESTAMP | +| **tool\_action\_registry** | Декларативный реестр шаблонов, кнопок и настроек эфемерности инструментов. | id INTEGER PK AUTOINCREMENT, tool\_name TEXT UNIQUE, category TEXT, bypass\_llm INTEGER, success\_template TEXT, follow\_up\_question TEXT, action\_type TEXT, buttons\_json TEXT, is\_active INTEGER, is\_ephemeral INTEGER, updated\_at TIMESTAMP | +| **session\_states** | Активные состояния сессий, черновики и счетчик idle\_turns. | session\_id TEXT PRIMARY KEY, state\_type TEXT, pending\_data TEXT, updated\_at TIMESTAMP | +| **chat\_messages** | История диалогов с поддержкой разделения на постоянные и эфемерные сообщения. | id INTEGER PK AUTOINCREMENT, session\_id TEXT, role TEXT, content TEXT, is\_ephemeral INTEGER DEFAULT 0, created\_at TIMESTAMP | +| **tasks** | Реестр задач операторов с привязкой к user\_id. | id INTEGER PK AUTOINCREMENT, task\_id TEXT, module TEXT, title TEXT, priority TEXT, status TEXT, due\_date TEXT, user\_id INTEGER, created\_at TIMESTAMP | +| **users** | Учетные записи операторов (хэши паролей bcrypt, права администратора). | id INTEGER PK AUTOINCREMENT, username TEXT UNIQUE, password\_hash TEXT, is\_admin INTEGER, full\_name TEXT, created\_at TIMESTAMP | +| **department\_synonyms** | База соответствия аббревиатур СКУД полным названиям отделов 1С. | id INTEGER PK AUTOINCREMENT, short\_name TEXT UNIQUE, full\_name TEXT, created\_at TIMESTAMP | +| **system\_reference** | Справочник команд и примеров промптов для операторов. | id INTEGER PK AUTOINCREMENT, category TEXT, title TEXT, example\_prompt TEXT, description TEXT | + +## --- + +**4\. Архитектурная структура каталогов** + +scud\_ai/ +├── config.py \# Конфигурация: пути, расчет дат, URL Ollama, параметры MS SQL +├── exceptions.json \# Декларативные фильтры исключений (ОВК, уборщики, подрядчики) +├── main\_etl.py \# Оркестратор ETL: экспорт СКУД/1С \-\> SQLite \-\> Excel/MD отчеты +├── project\_code\_snapshot.md \# Полный монолитный исходный код всех файлов репозитория +├── core/ +│ └── database.py \# Драйвер SQLite (WAL), функции сохранения/загрузки срезов +├── services/ +│ ├── scud\_export.py \# Прямой SQL-экспорт из MS SQL «Орион Pro» +│ ├── zup\_extractor.py \# Прямой SQL-экспорт из MS SQL 1С:ЗУП 3.1 +│ ├── data\_loader.py \# Загрузка данных, нормализация ФИО, расчет первой активности +│ ├── data\_validator.py \# Проверка свежести и наличия файлов за Вчера и Сегодня +│ ├── excel\_exporter.py \# Генерация сводки и детального отчета в Excel (openpyxl) +│ ├── text\_reporter.py \# Построение итогового Markdown-отчета через Qwen 2.5 +│ ├── ai\_verifier.py \# Модули ИИ-аудита, Fuzzy Matching и детекции сбоев СКУД +│ ├── feedback\_loop.py \# Интерактивный консольный модуль Human-in-the-Loop +│ ├── knowledge\_base.py \# Интерфейс работы с базой знаний правил +│ └── share\_copier.py \# Резервное скачивание файлов 1С с шары /mnt/scud\_share +├── scripts/ +│ ├── db\_cli.py \# CLI-утилита управления БД, снапшотами, дампами и сессиями +│ ├── fix\_snapshots.py \# Скрипт переиндексации и нормализации снапшотов +│ ├── init\_tool\_registry.py \# Инициализация декларативного реестра инструментов +│ └── diagnostics/ +│ ├── inspect\_db.py \# Диагностика структуры таблиц и количества записей +│ ├── inspect\_ephemeral.py \# Анализ распределения постоянных и эфемерных сообщений +│ ├── inspect\_files.py \# Аудит файлов и размеров на диске +│ └── show\_tree.py \# Вывод дерева каталогов +├── modules/web\_api/ +│ ├── main.py \# Точка входа FastAPI приложения, роутеры и раздача статики +│ ├── routers/ (admin, auth, chat, tasks) \# REST эндпоинты +│ ├── llm/ +│ │ ├── agent.py \# Координатор диалога, Tool Calls и Topic Drift Guard +│ │ ├── schemas.py \# Описание JSON-схем инструментов для Ollama +│ │ ├── file\_parser.py \# Мультимодальный парсер файлов (PDF, PNG, XLSX, TXT) +│ │ ├── db\_tools.py \# Фасадные вызовы функций БД для агента +│ │ ├── core/ (calendar\_utils, fast\_path, ollama\_client, prompt\_merger, tool\_injector) +│ │ └── db/ (connection, db\_chat, db\_prompts, db\_snapshots, db\_tasks) +│ └── static/ (index.html, css/styles.css, js/app.js, js/auth.js, js/chat.js, js/tasks.js) +└── docs/ + └── development\_roadmap.md \# Стратегическая дорожная карта и архитектурный бэклог + +## --- + +**5\. Справочник команд CLI (HOWTO)** + +### **ETL Пайплайн (main\_etl.py):** + +> * python main\_etl.py — стандартный дневной запуск (экспорт из MS SQL, сохранение снапшота, генерация отчетов). +> * python main\_etl.py \--skip-export — построение отчетов по последнему имеющемуся снапшоту из SQLite без повторного запроса к MS SQL. +> * python main\_etl.py \--snapshot 20260805-001 — точный расчет отчетов по указанному составному ID снапшота. +> * python main\_etl.py \-d — запуск в режиме расширенной отладки (DEBUG). + +### **CLI базы данных (scripts/db\_cli.py):** + +> * python scripts/db\_cli.py stats — статистика по количеству строк во всех таблицах БД. +> * python scripts/db\_cli.py snapshots \[ДД.ММ.ГГГГ\] — просмотр реестра снапшотов с сортировкой по времени создания (свежие сверху). +> * python scripts/db\_cli.py scud \[ДД.ММ.ГГГГ\] \[--snapshot ID\] \[--export-xlsx NAME\] — инспекция логов СКУД и экспорт в Excel. +> * python scripts/db\_cli.py absences \[ДД.ММ.ГГГГ\] — список кадровых отклонений из 1С:ЗУП. +> * python scripts/db\_cli.py anomalies — история выявленных аномалий. +> * python scripts/db\_cli.py rules — правила базы знаний компании. +> * python scripts/db\_cli.py prompts — системные промпты из system\_prompts. +> * python scripts/db\_cli.py tools — декларативный реестр действий инструментов и кнопок. +> * python scripts/db\_cli.py sessions — активные сессионные стейты и висящие черновики. +> * python scripts/db\_cli.py snapshot del \[ID\] — удаление конкретного снапшота по ID. +> * python scripts/db\_cli.py snapshot del \--day \[ДД.ММ.ГГГГ\] — удаление всех снапшотов за день. +> * python scripts/db\_cli.py dump \[backup.xlsx\] — полный дамп всех таблиц базы данных в многостраничный Excel. + +## --- + +**6\. Стратегическая дорожная карта и нерешенные задачи (Roadmap & Backlog)** + +> 1. **Масштабирование механизма эфемерного контекста на все инструменты (Rollout Roadmap):** + * **Снапшоты (snapshots):** Перевод вывода объемных таблиц срезов в эфемерный режим (is\_ephemeral \= 1), внедрение интерактивного подтверждения при удалении снапшотов по ID/дате через состояния SNAPSHOTS\_PREVIEW / SNAPSHOTS\_FOLLOWUP. + * **База знаний и аномалии (kb & anomalies):** Вывод справочников команд, правил арбитража и списков аномалий исключительно во временный слой для исключения «залипания» модели на устаревших правилах из истории сообщений. + * **Декларативная унификация в tool\_action\_registry:** Расширение схемы таблицы полями followup\_state\_type, auto\_purge\_on\_exit, guard\_question\_template для устранения хардкода имен функций в agent.py. +> 2. **Модуль анализа внутридневных перемещений («Контроль перекуров»):** + * Анализ интервалов между событиями OUT (выход) и IN (возврат) по журналу pLogData. + * Подсчет суммарного времени нахождения вне рабочего места в течение смены с отсечением технологической нормы (30–40 минут). + * Вывод аналитического блока и злостных нарушителей в отдельный лист Excel-отчета и итоговую Markdown-сводку. +> 3. **Интерактивный Task Planner & Roadmap Engine:** + * Специализированный инструмент для ведения динамических проектных планов и чек-листов с чекбоксами (\[ \] / \[x\]) прямо в диалоге с синхронизацией в таблицу tasks. +> 4. **Изолированная среда исполнения кода (Code Execution Sandbox):** + * Безопасный изолированный Docker/gVisor контейнер для динамического выполнения генерируемого моделью Python/Pandas кода на лету с возвратом stdout/stderr и графиков в контекст. +> 5. **Автоматизация расписания срезов и Telegram-алерты:** + * Автозапуск выгрузок в 12:00, 17:00 и 19:00 через systemd-таймеры с фиксацией snapshot\_time. + * Отправка мгновенных алертов о массовых сбоях турникетов (\>5%) и критических кадровых конфликтах дежурному системному администратору в Telegram. \ No newline at end of file diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..780cc22 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,61 @@ +# 🗺️ ДОРОЖНАЯ КАРТА И АРХИТЕКТУРНЫЙ РОАДМАП ПРОЕКТА 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 контуре. \ No newline at end of file diff --git a/docs/SCUD Orion AI — Полная энциклопедическая хроника, архитектурный паспорт и технический контекст (v4.0).md b/docs/SCUD Orion AI — Полная энциклопедическая хроника, архитектурный паспорт и технический контекст (v4.0).md new file mode 100644 index 0000000..e558978 --- /dev/null +++ b/docs/SCUD Orion AI — Полная энциклопедическая хроника, архитектурный паспорт и технический контекст (v4.0).md @@ -0,0 +1,223 @@ +# **SCUD Orion AI — Полная энциклопедическая хроника, архитектурный паспорт, разбор всех инцидентов и технический контекст проекта** + +**Версия документа:** 4.0 (Исчерпывающий монолитный контекстный паспорт для новой сессии) +**Стек технологий и инфраструктура:** Python 3.11+, FastAPI (Uvicorn), SQLite (WAL mode, PRAGMA synchronous=NORMAL, внешние ключи ON), Ollama REST API (Qwen 2.5:14b — основной текстовый агент и классификатор, Qwen 2.5-VL:7b-q8\_0 / Qwen 3-VL:8b — OCR и зрение), MS SQL Server 2019/2022 (СКУД «Орион Pro» ЗАО НВП «Болид», 1С:ЗУП 3.1 через pyodbc / ODBC Driver 18), сетевая Samba-шара (/mnt/scud\_share), Git/Gitea, HTML5 / Vanilla JS / Tailwind CSS. + +## --- + +**1\. Исходный бизнес-контекст, роли систем и фундаментальные задачи** + +Проект решает комплексную проблему контроллинга трудовой дисциплины, автоматического сопоставления кадровых фактов и защиты от рассинхронизации мастер-данных на предприятии со штатом \~270–276 сотрудников. + +### **Источники первичных данных:** + +> * **СКУД «Орион Pro» (Болид):** База данных MS SQL Server (SERVER: 172.16.31.221\\SQL, DB: Orion-14.01.21-1). Основные таблицы: + * pLogData — журнал всех сырых физических транзакций турникетов, калиток и внутренних дверей. Ключевые поля: TimeVal (время сервера), DeviceTime (время контроллера), HozOrgan / Hrk (ID сотрудника), Event (код события: 26, 28, 32 — вход; 27, 29, 33 — выход; 54, 55, 64, 65 — внутренние считыватели), Mode (направление: 1 — IN, 2 — OUT), ReaderIndex, DoorIndex. + * pList — реестр физических лиц и пропусков (ФИО, табельный номер, статус архива). + * PDivision, PPost — справочники подразделений и должностей СКУД. +> * **1С:ЗУП 3.1 (Кадры):** База данных MS SQL Server (SERVER: ACCOUNT-01, DB: ZUP30). + * dbo.\_Reference299 — эталонный справочник сотрудников. + * dbo.\_InfoRg16921 — периодический регистр сведений состояний сотрудников (отпуска основные, больничные, командировки, декретные отпуска, отпуска без сохранения оплаты). + * Файловые выгрузки с сетевой шары /mnt/scud\_share: файлы Штат\_ДД\_ММ\_ГГГГ.xlsx (шапка 7–8 строк) и Отсутствия\_ДД\_ММ\_ГГГГ.xlsx (шапка 3–4 строки). +> * **Реестры статических исключений:** static\_reason\_workers.csv (реестр удаленщиков и постоянных разъездных сотрудников), exceptions.json (декларативный фильтр обслуживающего персонала и спец-отделов). + +## --- + +**2\. Подробнейшая пошаговая хронология разработки, эволюция идей и разбор ошибок** + +### **Фаза 1: Первые эксперименты с LLM, выявление галлюцинаций и гибридная парадигма** + +> * **Попытка передачи сырых списков в чат:** Первоначальная попытка скормить модели Qwen 2.5:14b список из сотен ФИО с инструкцией *«запомни список 1С»* показала, что у LLM нет постоянной памяти между сессиями. Модель теряла контекст, путала фамилии и выдавала ответы с троеточиями ('Васильев Л.И.', ...) или придумывала несуществующие строки (*«ЛЕНМОРНИИПРОЕКТ ОЭС Варламова Мария»*). +> * **Ошибки openpyxl vs pandas:** Первая генерация кода моделью использовала библиотеку openpyxl с построчным перебором ячеек, жестко зашитыми опечатками (поиск слова 'фиио') и падением по FileNotFoundError. +> * **Переход к детерминированному пайплайну:** Принято ключевое архитектурное решение — **LLM не считает математику и не фильтрует 300 строк текста**. Всю механическую работу (ETL, слияние, фильтрацию) выполняет быстрый детерминированный Python (pandas, SQL), а локальная LLM выступает исключительно в роли «умного ревизора» на этапе сопоставления сомнительного несовпавшего остатка (Diff). + +### **Фаза 2: Нормализация ФИО, кодировки и коллизия совместителей (Кейс Балаевой О.Г.)** + +> * **Символьные ловушки:** + * Латинские буквы-двойники (омоглифы a, c, e, o, p, x, y, случайно набранные в английской раскладке). + * Служебные суффиксы в скобках, добавляемые 1С ((осн.), (совм.), (внешн.)). + * Буквы ё/е, двойные пробелы, неразрывные пробелы \\xa0 и случайный ввод цифры 0 вместо буквы О. +> * **Функция normalize\_fio():** Разработан строгий конвейер очистки: +> `def normalize_fio(fio):` +> `if not fio or not isinstance(fio, str): return ""` +> `fio_clean = re.sub(r'\(.*?\)', '', fio) # Удаление скобок` +> `fio_clean = fio_clean.replace('\xa0', ' ')` +> `trans = str.maketrans('aceopxyABCEHKMOPTX', 'асеорхуАВСЕНКМОРТХ')` +> `fio_clean = fio_clean.translate(trans)` +> `fio_clean = fio_clean.replace('ё', 'е').replace('Ё', 'Е')` +> `return " ".join(fio_clean.strip().split()).title()` +> * **Коллизия 270 vs 271:** В СКУД числилось 270 уникальных человек, а в 1С — 271 строка. Ручной разбор показал, что **Балаева Ольга Геннадьевна** занимает две ставки в 1С (*Руководитель управления персоналом* и *Руководитель режимно-секретного отдела*) при одном физическом пропуске в СКУД. +> * **Решение:** Внедрена обязательная группировка штата 1С по fio\_clean с объединением должностей и подразделений через " / ". + +### **Фаза 3: Защита Entity Resolution от однофамильцев (Инцидент с Семеновыми)** + +> * **Сбой модели:** При передаче несовпавших ФИО в Qwen 2.5 модель ошибочно сопоставила *«Семенову Юлию Борисовну»* (которая была на работе) с *«Семеновой Диной Дмитриевной»* (находившейся в отпуске), посчитав это опечаткой в имени из\-за одинаковой фамилии. В результате Юлия Борисовна получила статус нарушителя («в отпуске, но пришла»). +> * **Двухконтурная защита:** + 1. В системный промпт ИИ зашито категорическое правило: однофамильцы с разными именами/отчествами — строго разные люди, объединение запрещено. + 2. В Python встроен дублирующий барьер: если фамилии совпадают, но инициалы/первые буквы имен различаются, связь ИИ принудительно аннулируется с логированием ⚠️ \[Блокировка фейка\]. + +### **Фаза 4: Декларативные исключения и синонимы отделов** + +> * **Переход к exceptions.json:** Исключение клининга, дворников, гардеробщиков и хозяйственного персонала отдела «ЭТО» вынесено из кода в конфигурационный файл exceptions.json (поддержка списков departments, positions, fio, position\_keywords). +> * **База синонимов отделов (department\_synonyms):** Для сопоставления сокращений СКУД с длинными названиями 1С (например, *«ОВК» ⟷ «Отдел внутреннего контроля»*) создана таблица SQLite, автоматически обучаемая при подтверждениях оператора. + +### **Фаза 5: Аппаратный сбой СКУД 04.08.2026, разгадка «исчезающих неизвестных» и Первая активность** + +> * **Анатомия сбоя:** Утром 04.08.2026 зависли регистраторы входа на главном КПП. У 119 сотрудников не записалось событие входа (time\_in \= 'Нет входа'). Из них 57 человек были официально закрыты документами 1С (отпуска, командировки). Оставшиеся \~74 человека реально пришли на работу, но утренней отметки не имели. Однако вечером сводка выдала всего 4–7 «неизвестных». +> * **Вскрытие механизма ошибки:** Было обнаружено, что скрипт агрегации видел дневные перемещения сотрудников (выходы на обед, считыватели туалетов, внутренние двери) и автоматически выставлял Статус \= 'Присутствовал' и is\_present \= 1\. Из-за этого люди попадали в категорию «Итого на работе», а массовый аппаратный сбой скрывался. +> * **Внедрение «Первой активности»:** + 1. В базу данных и выгрузки Excel добавлена колонка **first\_activity («Первая активность»)** — минимальное время любого события дня в здании (MIN(TimeVal) AS FirstRawEvent без учета направления двери). + 2. Если time\_in \== 'Нет входа', но first\_activity \!= '—', записи присваивается anomaly\_flag \= 'ANOMALY\_NO\_IN\_HAS\_ACTIVITY', а is\_present принудительно сбрасывается в 0\. + 3. Внедрен модуль analyze\_scud\_mass\_failure\_ai(): при превышении порога в 5% смены (\>10 человек) ИИ поднимает тревогу о массовом сбое контроллеров КПП. +> * **Кейс Равина В.Э.:** Сотрудник находился в официальном отпуске, утреннего входа не имел, но пришел после обеда (первая активность 14:11:06, выход 17:18:38). Система корректно зафиксировала истинную кадровую аномалию (физическое присутствие при официальном отпуске). + +### **Фаза 6: Архитектурная миграция под Gitea и система срезов (снапшотов)** + +> * **Безопасный рефакторинг:** Монолит разделен на слои (core/, services/, scripts/, modules/web\_api/) через git mv с сохранением истории коммитов в ветке refactor/modular-structure. +> * **Формат ID снапшота:** Введена схема \[Y\]YYYYMMDD-NNN (например, Y20260804-001 для вчерашнего зафиксированного вечернего среза на 22:00 и 20260805-002 для дневного). +> * **Оптимизация экспорта:** Если в SQLite уже зафиксирован вчерашний срез с префиксом Y, повторный тяжелый SQL-запрос к СКУД Орион не выполняется (функция has\_yesterday\_final\_snapshot). +> * **Инспекция и CLI:** Написан инструмент scripts/db\_cli.py с командами stats, snapshots, scud, absences, anomalies, rules, prompts, tools, sessions, dump, snapshot del. + +### **Фаза 7: Web API, Generative UI, управление памятью LLM и Topic Drift Guard** + +> * **FastAPI & Безопасность:** Развернут Web API с JWT-авторизацией (bcrypt), изолированными задачами пользователей (tasks с привязкой к user\_id), гостевым режимом и ролевой моделью. +> * **Проблема деградации контекста LLM:** Вывод объемных списков задач, текстов системных промптов и таблиц снапшотов засорял контекстное окно чата, приводя к галлюцинациям и «залипанию» модели на старых правилах. +> * **Решение проблемы памяти (Topic Drift & Ephemeral Context):** + * В таблицу chat\_messages добавлен флаг is\_ephemeral \= 1 для временных сообщений. + * Введены двухфазные сессии (PROMPT\_PREVIEW → PROMPT\_FOLLOWUP) и детерминированный счетчик отвлечений idle\_turns. + * **Context Guard:** На 3-м шаге отвлечения оператора на сторонние темы система отвечает на вопрос и выводит интерактивное напоминание с кнопками \[Завершить настройку\] / \[Показать промпт\]. При idle\_turns \> 3 происходит автоочистка сессии вызовом db\_purge\_ephemeral\_messages(). + * **Fast-Path:** Прямой перехват команд подтверждения («да», «подтверждаю», «сохранить») и выхода («нет, спасибо», «закончить настройку») без обращения к LLM (ответ за 0.05 сек). + * **Декларативный реестр действий (tool\_action\_registry):** Хранение шаблонов ответов, типов кнопок и признаков эфемерности в SQLite. +> * **Generative UI (Интерактивные карточки задач):** Инструмент db\_get\_tasks возвращает полезную нагрузку TASK\_INTERACTIVE\_CARD. Фронтенд рендерит живой виджет с табами (Все / В работе / В планах / Завершенные), чекбоксами и inline-созданием задач через REST API без вызова LLM. + +## --- + +**3\. Полная схема базы данных SQLite (data/scud\_orion\_ai.db)** + +| Таблица | Назначение и бизнес-логика | Полная структура колонок | +| :---- | :---- | :---- | +| **scud\_logs** | Хранение логов проходов СКУД, расчетного времени и снапшотов. | id (PK), log\_date, fio, fio\_clean, department, position, time\_in, first\_activity, time\_out, time\_in\_building, is\_present, anomaly\_flag, snapshot\_time, snapshot\_id, created\_at | +| **zup\_staff** | Штатное расписание 1С:ЗУП на даты срезов. | id (PK), snapshot\_date, fio, fio\_clean, department, position, created\_at | +| **zup\_absences** | Официальные кадровые отклонения 1С (отпуска, больничные, командировки, декреты). | id (PK), absence\_date, fio, fio\_clean, absence\_type, created\_at | +| **anomalies\_history** | Журнал выявленных кадровых и аппаратных аномалий. | id (PK), anomaly\_date, fio, anomaly\_type, details, human\_status, created\_at | +| **ai\_knowledge\_base** | Глобальные правила арбитража и приоритеты компании для ИИ. | id (PK), rule\_text, added\_by, created\_at | +| **system\_prompts** | Версионные системные промпты агента (активный промпт по is\_active=1). | id (PK), name, prompt\_text, is\_active, updated\_at | +| **tool\_action\_registry** | Декларативный реестр шаблонов, кнопок и настроек эфемерности инструментов. | id (PK), tool\_name, category, bypass\_llm, success\_template, follow\_up\_question, action\_type, buttons\_json, is\_active, is\_ephemeral, updated\_at | +| **session\_states** | Активные состояния сессий, черновики и счетчик idle\_turns. | session\_id (PK), state\_type, pending\_data, updated\_at | +| **chat\_messages** | История диалогов с поддержкой разделения на постоянные и эфемерные сообщения. | id (PK), session\_id, role, content, is\_ephemeral, created\_at | +| **tasks** | Реестр задач пользователей с привязкой к user\_id. | id (PK), task\_id, module, title, priority, status, due\_date, user\_id, created\_at | +| **users** | Учетные записи операторов (хэши паролей bcrypt, права администратора). | id (PK), username, password\_hash, is\_admin, full\_name, created\_at | +| **department\_synonyms** | База знаний соответствия аббревиатур СКУД полным названиям отделов 1С. | id (PK), short\_name, full\_name, created\_at | +| **system\_reference** | Справочник команд и примеров промптов для операторов. | id (PK), category, title, example\_prompt, description | + +## --- + +**4\. Актуальная архитектура каталогов и назначение файлов** + +`scud_ai/` +`├── config.py # Конфигурация: пути, расчет дат, URL Ollama, параметры MS SQL` +`├── exceptions.json # Декларативные фильтры исключений (ОВК, уборщики, подрядчики)` +`├── main_etl.py # Оркестратор ETL: экспорт СКУД/1С -> SQLite -> Excel/MD отчеты` +`├── project_code_snapshot.md # Полный исходный код всех файлов репозитория` +`├── core/` +`│ └── database.py # Драйвер SQLite (WAL), функции сохранения/загрузки срезов` +`├── services/` +`│ ├── scud_export.py # Прямой SQL-экспорт из MS SQL «Орион Pro»` +`│ ├── zup_extractor.py # Прямой SQL-экспорт из MS SQL 1С:ЗУП 3.1` +`│ ├── data_loader.py # Загрузка данных, нормализация ФИО, расчет первой активности` +`│ ├── data_validator.py # Проверка свежести и наличия файлов за Вчера и Сегодня` +`│ ├── excel_exporter.py # Генерация сводки и детального отчета в Excel (openpyxl)` +`│ ├── text_reporter.py # Построение итогового Markdown-отчета через Qwen 2.5` +`│ ├── ai_verifier.py # Модули ИИ-аудита, Fuzzy Matching и детекции сбоев СКУД` +`│ ├── feedback_loop.py # Интерактивный консольный модуль Human-in-the-Loop` +`│ ├── knowledge_base.py # Интерфейс работы с базой знаний правил` +`│ └── share_copier.py # Резервное скачивание файлов 1С с шары /mnt/scud_share` +`├── scripts/` +`│ ├── db_cli.py # CLI-утилита управления БД, снапшотами, дампами и сессиями` +`│ ├── fix_snapshots.py # Скрипт переиндексации и нормализации снапшотов` +`│ ├── init_tool_registry.py # Инициализация декларативного реестра инструментов` +`│ └── diagnostics/` +`│ ├── inspect_db.py # Диагностика структуры таблиц и количества записей` +`│ ├── inspect_ephemeral.py # Анализ распределения постоянных и эфемерных сообщений` +`│ ├── inspect_files.py # Аудит файлов и размеров на диске` +`│ └── show_tree.py # Вывод дерева каталогов` +`├── modules/web_api/` +`│ ├── main.py # Точка входа FastAPI приложения, роутеры и раздача статики` +`│ ├── routers/` +`│ │ ├── admin.py # Управление пользователями` +`│ │ ├── auth.py # JWT аутентификация и смена паролей` +`│ │ ├── chat.py # Эндпоинты диалогов (авторизованный и гостевой)` +`│ │ └── tasks.py # REST API управления задачами` +`│ ├── llm/` +`│ │ ├── agent.py # Координатор диалога, Tool Calls и Topic Drift Guard` +`│ │ ├── schemas.py # Описание JSON-схем инструментов для Ollama` +`│ │ ├── file_parser.py # Мультимодальный парсер файлов (PDF, PNG, XLSX, TXT)` +`│ │ ├── db_tools.py # Фасадные вызовы функций БД для агента` +`│ │ ├── core/` +`│ │ │ ├── calendar_utils.py # Формирование системного контекста дат и дней недели` +`│ │ │ ├── fast_path.py # Быстрый перехват команд подтверждения/отмены (0.05 с)` +`│ │ │ ├── ollama_client.py # HTTP-клиент взаимодействия с Ollama API` +`│ │ │ ├── prompt_merger.py # Парсинг и слияние правок системного промпта` +`│ │ │ └── tool_injector.py # Очистка сырых тегов и артефактов Tool Calls` +`│ │ └── db/` +`│ │ ├── connection.py # Подключение к scud_orion_ai.db` +`│ │ ├── db_chat.py # Сохранение истории и очистка эфемерных сообщений` +`│ │ ├── db_prompts.py # Системные промпты, стейты и реестр действий` +`│ │ ├── db_snapshots.py # Выборка и удаление снапшотов` +`│ │ └── db_tasks.py # CRUD операции с задачами пользователей` +`│ └── static/` +`│ ├── index.html # SPA разметка (модалки, чат, боковая панель задач)` +`│ ├── css/styles.css # Стили и мобильные оптимизации` +`│ └── js/` +`│ ├── app.js # Инициализация приложения` +`│ ├── auth.js # Логика входа, смены паролей и администрирования` +`│ ├── chat.js # Обработка сообщений, Drag&Drop, виджет задач и кнопки` +`│ └── tasks.js # Управление боковой панелью задач (Drawer)` +`└── docs/` + `└── development_roadmap.md # Стратегическая дорожная карта и архитектурный бэклог` + +## --- + +**5\. Справочник команд командной строки (CLI Reference)** + +### **Главный конвейер контроллинга (main\_etl.py)** + +> * python main\_etl.py — стандартный дневной запуск: экспорт свежих данных СКУД и 1С, сохранение снапшота в SQLite, генерация Excel и Markdown отчетов. +> * python main\_etl.py \--skip-export — построение отчетов по последнему имеющемуся снапшоту из SQLite без повторного запроса к MS SQL. +> * python main\_etl.py \--snapshot 20260805-001 — точный расчет отчетов по указанному составному ID снапшота. +> * python main\_etl.py \-d — запуск в режиме расширенной отладки (DEBUG). + +### **CLI-утилита управления SQLite базой данных (scripts/db\_cli.py)** + +> * python scripts/db\_cli.py stats — общая статистика количества записей по всем таблицам БД. +> * python scripts/db\_cli.py snapshots \[ДД.ММ.ГГГГ\] — просмотр реестра снапшотов с сортировкой по времени создания (самые свежие сверху). +> * python scripts/db\_cli.py scud \[ДД.ММ.ГГГГ\] \[--snapshot ID\] \[--export-xlsx NAME\] — просмотр логов СКУД и опциональный экспорт в Excel. +> * python scripts/db\_cli.py absences \[ДД.ММ.ГГГГ\] — список кадровых отклонений из 1С:ЗУП. +> * python scripts/db\_cli.py anomalies — история выявленных аномалий. +> * python scripts/db\_cli.py rules — правила базы знаний компании. +> * python scripts/db\_cli.py prompts — просмотр системных промптов из таблицы system\_prompts. +> * python scripts/db\_cli.py tools — вывод декларативного реестра действий инструментов и шаблонов кнопок. +> * python scripts/db\_cli.py sessions — просмотр активных сессионных стейтов и висящих черновиков. +> * python scripts/db\_cli.py snapshot del \[ID\] — удаление конкретного снапшота по ID. +> * python scripts/db\_cli.py snapshot del \--day \[ДД.ММ.ГГГГ\] — удаление всех снапшотов за выбранный день. +> * python scripts/db\_cli.py dump \[backup.xlsx\] — полный дамп всех таблиц базы данных в многостраничный Excel-файл. + +## --- + +**6\. Стратегическая дорожная карта развития (Roadmap & Backlog)** + +> 1. **Масштабирование механизма эфемерного контекста на все инструменты (Rollout Roadmap):** + * **Снапшоты (snapshots):** Перевод вывода объемных таблиц срезов в эфемерный режим (is\_ephemeral \= 1), внедрение интерактивного подтверждения при удалении снапшотов по ID/дате через состояния SNAPSHOTS\_PREVIEW / SNAPSHOTS\_FOLLOWUP. + * **База знаний и аномалии (kb & anomalies):** Вывод справочников команд, правил арбитража и списков аномалий исключительно во временный слой для исключения «залипания» модели на устаревших правилах из истории сообщений. + * **Декларативная унификация в tool\_action\_registry:** Расширение схемы таблицы полями followup\_state\_type, auto\_purge\_on\_exit, guard\_question\_template для устранения хардкода имен функций в agent.py. +> 2. **Модуль анализа внутридневных перемещений («Контроль перекуров»):** + * Анализ интервалов между событиями OUT (выход) и IN (возврат) по журналу pLogData. + * Подсчет суммарного времени нахождения вне рабочего места в течение смены с отсечением технологической нормы (30–40 минут). + * Вывод аналитического блока и злостных нарушителей в отдельный лист Excel-отчета и итоговую Markdown-сводку. +> 3. **Интерактивный Task Planner & Roadmap Engine:** + * Специализированный инструмент для ведения динамических проектных планов и чек-листов с чекбоксами (\[ \] / \[x\]) прямо в диалоге с синхронизацией в таблицу tasks. +> 4. **Изолированная среда исполнения кода (Code Execution Sandbox):** + * Безопасный изолированный Docker/gVisor контейнер для динамического выполнения генерируемого моделью Python/Pandas кода на лету с возвратом stdout/stderr и графиков в контекст. +> 5. **Автоматизация расписания срезов и Telegram-алерты:** + * Автозапуск выгрузок в 12:00, 17:00 и 19:00 через systemd-таймеры с фиксацией snapshot\_time. + * Отправка мгновенных алертов о массовых сбоях турникетов (\>5%) и критических кадровых конфликтах дежурному системному администратору в Telegram. \ No newline at end of file diff --git a/docs/development_roadmap.md b/docs/development_roadmap.md deleted file mode 100644 index 56ccd91..0000000 --- a/docs/development_roadmap.md +++ /dev/null @@ -1,41 +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 и отображение на дашборде. diff --git a/docs/in_progress/план_оптимизации_работы_с_контекстом.md b/docs/in_progress/план_оптимизации_работы_с_контекстом.md new file mode 100644 index 0000000..4587843 --- /dev/null +++ b/docs/in_progress/план_оптимизации_работы_с_контекстом.md @@ -0,0 +1,72 @@ +# План реализации + +## 1. Очистка от регулярок и костылей (`tool_injector.py`) +- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 4]. +- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (``)[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`. +- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов. + +--- + +## 3. Очистка эфемерных сообщений при завершении (`fast_path.py`) +- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 4]. +- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 4]. +- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 4]. +- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста. + +--- + +## 4. Инлайн-редактор в окне диалога (`core.js`) +- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 4]. +- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 4]. +- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`). +- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика. + +--- + +## 5. Тестирование и валидация системного промпта +- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 4]. +- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 4]. +- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 4]. + +--- + +## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) +- [ ] **Масштабирование UI задач:** + - Увеличить размер окна/виджета просмотра и управления задачами (`TASK_INTERACTIVE_CARD`) по аналогии с окном редактирования системного промпта для удобного управления большим списком. +- [ ] **Интерактивное управление и превью:** + - Карточка предпросмотра параметров создаваемой задачи (`db_add_task`) с кнопками `[Подтвердить]`, `[Отменить]`, `[✏️ Редактировать]`. + - Подтверждение опасных операций (удаление задачи `db_delete_task`) с кнопками валидации. + - Follow-up фаза с выводом актуализированного списка задач из `tool_action_registry`. +- [ ] **Доменная консолидация:** + - Объединить `db_add_task`, `db_update_task_status`, `db_delete_task` в единый диспетчер `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE"], ...)`[cite: 4]. + - Обновить `TOOLS_SCHEMA` в `schemas.py`[cite: 4]. + +--- + +## 7. Распространение на модуль снапшотов (`snapshots`) и правил (`rules`) +- [ ] **Консолидация модуля `snapshots`:** + - Интерактивное подтверждение массового удаления снапшотов за дату (`db_delete_snapshots`). + - Объединить операции удаления и управления срезами в единый `db_snapshots_edit(action: ["DELETE"], ...)`[cite: 4]. + - Авто-зачистка тяжелых таблиц снапшотов из контекста при переходе к другим темам. +- [ ] **Модуль правил арбитража (`rules`):** + - Редактирование правил базы знаний через единый паттерн узлов с подсветкой diff. + +--- + +## 8. Изолированная песочница кода (Code Execution Sandbox Engine) +- [ ] **Docker/gVisor контур:** + - Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups). + - Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only. +- [ ] **Динамические Python/Pandas вычисления:** + - Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету. + - Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата. \ No newline at end of file diff --git a/modules/web_api/llm/agent.py b/modules/web_api/llm/agent.py index 252c943..a341b8b 100644 --- a/modules/web_api/llm/agent.py +++ b/modules/web_api/llm/agent.py @@ -2,28 +2,25 @@ =============================================================================== FILE: modules/web_api/llm/agent.py PROJECT: SCUD Orion AI (Unified Architecture) -MODULE: web_api / llm (Core Agent & Function Calling Dispatcher) -ROLE: Главный оркестратор взаимодействия с Ollama LLM (Qwen 2.5), разбор вызовов - инструментов (Function Calling), интеграция с декларативным реестром - действий SQLite (tool_action_registry), детерминированный Fast-Path - для подтверждений, динамические кнопки и автоочистка эфемерных сообщений. +MODULE: web_api / llm (Core Agent Coordinator) +ROLE: Оркестратор диалога, диспетчер реляционных узлов промпта, + управление сессионными стейтами, Topic Drift Guard и очистка контекста. =============================================================================== """ -# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_IMPORTS] -import os +# ============================================================================= +# БЛОК 1: ИМПОРТЫ И ИНИЦИАЛИЗАЦИЯ СИСТЕМНЫХ МОДУЛЕЙ +# ============================================================================= import sys import json -import urllib.request -import urllib.error import logging import re from typing import List, Dict, Any, Tuple, Optional -# Импорт фасада базы данных +from .db.connection import get_db_connection from .db_tools import ( db_get_active_system_prompt, - db_add_system_prompt, + db_apply_prompt_node_action, db_get_tool_action, db_get_tasks, db_update_task_status, @@ -32,24 +29,24 @@ from .db_tools import ( db_get_rules, db_set_session_state, db_get_session_state, + db_clear_session_state, db_get_snapshots, db_delete_snapshots, - db_clear_session_state, db_get_current_server_time, db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages, db_get_stats, db_get_anomalies, - db_get_session_states, db_get_reference ) from .schemas import TOOLS_SCHEMA -from .core.calendar_utils import get_dynamic_calendar_context, parse_relative_date_ru +from .core.calendar_utils import get_dynamic_calendar_context from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed +from .core.ollama_client import call_ollama_chat +from .core.fast_path import handle_fast_path_intercept -# --- [SECTION 2: LOGGING CONFIGURATION] --- # ANCHOR[LOGGING_CONFIG] logger = logging.getLogger("SCUD_AGENT") logger.setLevel(logging.INFO) logger.propagate = False @@ -60,12 +57,10 @@ if not logger.handlers: handler.setFormatter(formatter) logger.addHandler(handler) -OLLAMA_URL = "http://192.168.11.3:11434/api/chat" -TEXT_MODEL = "qwen2.5:14b" -VISION_MODEL = "qwen2.5vl:7b-q8_0" - -# --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] --- # ANCHOR[CHAT_PROCESSOR] +# ============================================================================= +# БЛОК 2: ГЛАВНЫЙ КОНВЕЙЕР ОБРАБОТКИ СООБЩЕНИЙ ЧАТА +# ============================================================================= def process_chat_message( user_id: int, user_message: str, @@ -75,327 +70,343 @@ def process_chat_message( session_id: str = "web_session_main" ) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]: """ - Главный конвейер обработки входящего сообщения: - 1. Fast-Path перехват подтверждений/отмен при активном session_state. - 2. Перехват завершения работы ('нет, закончить настройку') с автоочисткой эфемерных сообщений. - 3. Формирование системного контекста и вызов Ollama. - 4. Выполнение вызванного Tool и опрос Data-Driven реестра действий. - 5. Возврат кортежа: (reply_text, chat_history, action_metadata). + Главная функция обработки сообщения: + 1. Очищает осиротевшие эфемерные сообщения. + 2. Перехватывает быстрые кнопки (Fast-Path). + 3. Собирает контекст и передает управление Ollama. + 4. Выполняет Tool Calls и управляет счетчиком Topic Drift. """ logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}") - # 3.1. Обогащение текста вложением (при наличии) - full_user_content = user_message - if file_context: - full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" - + # 🧹 ГАРБАДЖ-КОЛЛЕКТОР: Если стейт сессии пуст, удаляем висящие эфемерные сообщения session_state = db_get_session_state(session_id) - user_msg_clean = user_message.lower().strip(" .!?:;") + if not session_state: + db_purge_ephemeral_messages(session_id) - # --- [FAST-PATH 1: ПЕРЕХВАТ ЗАВЕРШЕНИЯ НАСТРОЙКИ С ОЧИСТКОЙ ЭФЕМЕРНОЙ ПАМЯТИ] --- - if user_msg_clean in ["нет, спасибо", "нет, закончить настройку", "закончить настройку", "завершить", "нет"]: - db_clear_session_state(session_id) - deleted_count = db_purge_ephemeral_messages(session_id) - logger.info(f"🧹 Завершена работа с инструментом. Удалено эфемерных сообщений: {deleted_count}") - - reply_text = "Хорошо. Настройка завершена, контекст диалога чист. Чем я могу помочь дальше?" - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) - return reply_text, db_get_chat_history(session_id), None + full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message - # --- [FAST-PATH 2: ПЕРЕХВАТ ПОДТВЕРЖДЕНИЯ / ОТМЕНЫ ПРЕВЬЮ ПРОМПТА] --- - if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": - if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: - db_add_system_prompt("main_agent", session_state.get("pending_data", "")) - db_clear_session_state(session_id) - - action_cfg = db_get_tool_action("db_confirm_prompt_preview") - reply_text = action_cfg["success_template"] if action_cfg else "✅ Системный промпт успешно сохранен и применен в базе данных." - if action_cfg and action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - - db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION", - "buttons": action_cfg.get("buttons", []) if action_cfg else [] - } + # Перехват нажатия кнопок («Подтвердить», «Отменить», «Завершить») без вызова LLM + fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state) + if fast_path_res: + return fast_path_res - elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]: - db_clear_session_state(session_id) - - action_cfg = db_get_tool_action("db_cancel_prompt_preview") - reply_text = action_cfg["success_template"] if action_cfg else "❌ Изменения системного промпта отменены." - if action_cfg and action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - - db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION", - "buttons": action_cfg.get("buttons", []) if action_cfg else [] - } - - # 3.2. Сохраняем входящее сообщение в историю диалога + # Сохранение входящего сообщения оператора is_user_ephemeral = 1 if session_state else 0 db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=is_user_ephemeral) - - # 3.3. Извлекаем полную актуальную историю для передачи в LLM db_history = db_get_chat_history(session_id, limit=20) + + # ========================================================================= + # БЛОК 3: ДИНАМИЧЕСКАЯ СБОРКА СИСТЕМНОГО ИНСТРУКТАЖА + # ========================================================================= dynamic_prompt_text = db_get_active_system_prompt() calendar_context = get_dynamic_calendar_context() user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" + # Извлечение текущего состояния и счетчика отвлечений (idle_turns) + current_state_type = session_state.get("state_type") if session_state else None + state_data = session_state.get("data_json") or {} if session_state else {} + if not isinstance(state_data, dict): + state_data = {} + idle_turns = state_data.get("idle_turns", 0) + + active_state_context = "" + if current_state_type == "PROMPT_PREVIEW": + active_state_context = ( + "\n[ВНИМАНИЕ: АКТИВНЫЙ РЕЖИМ ПРЕДПРОСМОТРА ПРОМПТА]\n" + "- Сейчас открыт предпросмотр изменения системного промпта.\n" + "- Если пользователь просит изменить, скорректировать формулировку или удалить пункт — вызови инструмент db_prompt_node_edit.\n" + "- Если пользователь переключился на другую тему — ответь на его вопрос кратко и по делу.\n" + ) + elif current_state_type == "PROMPT_FOLLOWUP": + active_state_context = ( + "\n[ВНИМАНИЕ: АКТИВНА СЕССИЯ РЕДАКТИРОВАНИЯ СИСТЕМНОГО ПРОМПТА]\n" + "- Оператор только что применил предыдущее изменение или запросил просмотр промпта.\n" + "- Любые команды вида 'добавь X.Y', 'удали X.Y', 'измени X.Y' ОЗНАЧАЮТ ПРОДОЛЖЕНИЕ РАБОТЫ С СИСТЕМНЫМ ПРОМПТОМ -> ВЫЗЫВАЙ db_prompt_node_edit.\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\n" + f"- {calendar_context}\n" + f"{active_state_context}\n" f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n" - f"1. Для любых изменений системного промпта (добавить, удалить, изменить пункт) ВСЕГДА вызывай функцию db_preview_prompt_merge(prompt_text=...).\n" + f"1. Для ЛЮБЫХ операций с системным промптом ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_prompt_node_edit:\n" + f" - 'удали 2.9' / 'удалить пункт 2.9' -> ВЫЗОВ db_prompt_node_edit(action='DELETE', section_id=2, item_id=9, content='')\n" + f" - 'добавь 3.4 Текст' / 'добавить пункт 3.4' -> ВЫЗОВ db_prompt_node_edit(action='ADD', section_id=3, item_id=4, content='Текст')\n" + f" - 'измени 1.2 Текст' -> ВЫЗОВ db_prompt_node_edit(action='UPDATE', section_id=1, item_id=2, content='Текст')\n" + f" КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО отвечать текстом вроде 'Удален пункт... Напишите подтверждаю'. Только Function Call!\n" f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n" - f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n" - f"4. Никогда не симулируй выполнение функций в виде обычного текста. Если требуется действие — сразу вызывай соответствующий инструмент.\n\n" - f"[ПРИМЕРЫ ВЫЗОВА ИНСТРУМЕНТОВ]:\n" - f"- Пользователь: 'добавь пункт 3.4. Работать от сюда и до заката.' -> Вызов: db_preview_prompt_merge(prompt_text='3.4. Работать от сюда и до заката.')\n" - f"- Пользователь: 'удали пункт 3.4' -> Вызов: db_preview_prompt_merge(prompt_text='3.4')\n" - f"- Пользователь: 'покажи системный промпт' -> Вызов: db_get_system_prompt()\n" - f"- Пользователь: 'покажи мои задачи' -> Вызов: db_get_tasks()\n\n" + f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n\n" f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}" ) - llm_options = { - "num_predict": 8192, - "num_ctx": 8192, - "temperature": 0.1, - "repeat_penalty": 1.1, - "presence_penalty": 0.5, - "top_p": 0.9 - } - user_msg_object = {"role": "user", "content": full_user_content} - # --- [SECTION 4: ROUTING & OLLAMA PAYLOAD] --- # ANCHOR[PAYLOAD_BUILD] - if image_b64: - user_msg_object["images"] = [image_b64] - messages = [ - {"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву без отсебятины."}, - user_msg_object - ] - payload = {"model": VISION_MODEL, "messages": messages, "stream": False, "options": llm_options} - else: - clean_db_history = [dict(m) for m in db_history] - for m in clean_db_history: - m.pop("images", None) - messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object] - payload = {"model": TEXT_MODEL, "messages": messages, "tools": TOOLS_SCHEMA, "stream": False, "options": llm_options} - # --- [SECTION 5: EXECUTION & TOOL ROUTING] --- # ANCHOR[TOOL_ROUTER] + # ========================================================================= + # БЛОК 4: ВЫЗОВ НЕЙРОСЕТИ И МАРШРУТИЗАЦИЯ FUNCTION CALLING + # ========================================================================= try: - req = urllib.request.Request( - OLLAMA_URL, - data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"} - ) - with urllib.request.urlopen(req) as response: - res_data = json.loads(response.read().decode("utf-8")) - msg = res_data.get("message", {}) - tool_calls = msg.get("tool_calls", []) - raw_text_content = msg.get("content", "") + # Режим Vision (OCR) + if image_b64: + user_msg_object["images"] = [image_b64] + messages = [ + {"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву."}, + user_msg_object + ] + msg = call_ollama_chat(messages, is_vision=True) + else: + # Текстовый диалог + clean_db_history = [dict(m) for m in db_history] + for m in clean_db_history: + m.pop("images", None) + messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object] + msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False) - #tool_calls = inject_tools_if_needed(user_message, raw_text_content, tool_calls) + raw_text_reply = msg.get("content", "") + tool_calls = msg.get("tool_calls", []) + 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) - - for tool in tool_calls: - fn_name = tool["function"]["name"] - fn_args = tool["function"].get("arguments", {}) - logger.info(f"🚀 Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") - tool_result_content = "" + # Исполнение вызванных моделью инструментов + if tool_calls: + logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})") + messages.append(msg) + tool_names_called = [] - action_cfg = db_get_tool_action(fn_name) + # ----------------------------------------------------------------- + # TOPIC DRIFT GUARD ДЛЯ ИНСТРУМЕНТОВ: + # Если вызваны сторонние инструменты (снапшоты, задачи, статы) + # — мгновенно закрываем сессию и вычищаем эфемерный контекст + # ----------------------------------------------------------------- + is_prompt_tool = any(t["function"]["name"] in ["db_prompt_node_edit", "db_get_system_prompt", "db_get_system_prompts"] for t in tool_calls) + if not is_prompt_tool and session_state: + logger.info(f"Смена темы на инструмент {tool_calls[0]['function']['name']}. Закрываем сессию и очищаем эфемерный контекст.") + db_clear_session_state(session_id) + db_purge_ephemeral_messages(session_id) + session_state = None + + 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 = "" + + # --- 4.1. Модуль задач --- + if fn_name == "db_get_tasks": + raw_tasks = db_get_tasks(user_id) + 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 + } + + # --- 4.2. Прямой просмотр системного промпта из SQLite --- + elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]: + active_prompt = db_get_active_system_prompt() + reply_text = f"Актуальный системный промпт:\n\n{active_prompt}" - if fn_name == "db_confirm_prompt_preview": - if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": - db_add_system_prompt("main_agent", session_state.get("pending_data", "")) - db_clear_session_state(session_id) - - if action_cfg and action_cfg.get("bypass_llm"): - reply_text = action_cfg["success_template"] - if action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - - is_eph = action_cfg.get("is_ephemeral", 1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type"), - "buttons": action_cfg.get("buttons", []) - } - tool_result_content = json.dumps({"status": "success"}, ensure_ascii=False) + # Переводим сессию в PROMPT_FOLLOWUP для отслеживания Topic Drift + 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 + + # --- 4.3. Реляционное изменение промпта (ADD / UPDATE / DELETE) --- + 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() + 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} + + if action == "DELETE": + nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (sec_id, itm_id)} + else: + nodes_dict_for_draft = dict(nodes_dict) + 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: - err_reply = "⚠️ Нет активного превью для подтверждения." - db_save_chat_message(session_id, "assistant", err_reply, is_ephemeral=1) - return err_reply, db_get_chat_history(session_id), None + draft_lines.append(f" {s_id}.{i_id}. {txt}") + merged_prompt = "\n".join(draft_lines) - elif fn_name == "db_cancel_prompt_preview": - db_clear_session_state(session_id) - if action_cfg and action_cfg.get("bypass_llm"): - reply_text = action_cfg["success_template"] - if action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - - is_eph = action_cfg.get("is_ephemeral", 1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type"), - "buttons": action_cfg.get("buttons", []) - } - tool_result_content = json.dumps({"status": "cancelled"}, ensure_ascii=False) + # Формирование HTML Diff с подсветкой + diff_lines = [] + curr_sec = None + display_nodes = dict(nodes_dict) + if action != "DELETE": + display_nodes[(sec_id, itm_id)] = content - 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) - - 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_tasks": - tool_result_content = json.dumps(db_get_tasks(user_id), ensure_ascii=False) - - 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) - - 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_delete_snapshots": - res = db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str")) - if action_cfg and action_cfg.get("bypass_llm"): - reply_text = action_cfg["success_template"] - if action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - is_eph = action_cfg.get("is_ephemeral", 1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type"), - "buttons": action_cfg.get("buttons", []) - } - tool_result_content = json.dumps(res, ensure_ascii=False) - - elif fn_name == "db_get_reference": - tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False) - - # --- [SECTION 6: PROMPT MERGE & PREVIEW ENGINE] --- # ANCHOR[PROMPT_MERGE_LOGIC] - elif fn_name == "db_preview_prompt_merge": - proposed_text = fn_args.get("prompt_text") or fn_args.get("proposed_prompt") or "" - if isinstance(fn_args, str): - proposed_text = fn_args - - current_prompt = db_get_active_system_prompt() - user_msg_lower = user_message.lower() - - # 1. ОБРАБОТКА УДАЛЕНИЯ ПУНКТА - if any(w in user_msg_lower for w in ["удали", "стереть", "убрать", "вырежи", "удалить"]): - target_num_match = re.search(r'\d+(\.\d+)*', user_message) - target_num = target_num_match.group(0) if target_num_match else "" - - lines = current_prompt.splitlines() - if target_num: - new_lines = [line for line in lines if not line.strip().startswith(f"{target_num}.")] + 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: + if action == "DELETE": + line_str = f' {s_id}.{i_id}. {txt} [УДАЛЕНИЕ]' + else: + line_str = f' {s_id}.{i_id}. {txt}' else: - new_lines = lines - proposed_text = "\n".join(new_lines) + line_str = f" {s_id}.{i_id}. {txt}" + diff_lines.append(line_str) - # 2. ОБРАБОТКА ДОБАВЛЕНИЯ / ИЗМЕНЕНИЯ ПУНКТА - elif proposed_text: - if len(proposed_text) < 500: - clean_item = proposed_text.strip() - for prefix in ["добавь пункт", "добавить пункт", "вставь пункт", "добавь"]: - if prefix in clean_item.lower(): - clean_item = re.sub(prefix, "", clean_item, flags=re.IGNORECASE).strip(" .:") + diff_html = "\n".join(diff_lines) - lines = current_prompt.splitlines() - new_lines = [] - inserted = False - for line in lines: - new_lines.append(line) - if "3.3." in line and not inserted: - item_str = clean_item if re.match(r'^\d+\.\d+\.', clean_item) else f"3.4. {clean_item}" - new_lines.append(f" {item_str}") - inserted = True - if not inserted: - new_lines.append(f" {clean_item}") - proposed_text = "\n".join(new_lines) + # Фиксация предпросмотра в session_states + 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 + }) - db_set_session_state(session_id, "PROMPT_PREVIEW", proposed_text) - preview_reply = ( - f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n" - f"{proposed_text}\n\n" - f"Для применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)." - ) - db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1) - return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id), { - "type": "PROMPT_PREVIEW", - "buttons": [ - {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, - {"label": "Отменить", "value": "отмена", "style": "danger"} - ] - } + 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() - elif fn_name == "db_get_rules": - tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) + 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, + "baseline_prompt": baseline_prompt, + "buttons": [ + {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, + {"label": "Отменить", "value": "отмена", "style": "danger"}, + {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"} + ] + } - 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) + # --- 4.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) + tool_result_content = json.dumps(snapshots_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_get_current_server_time": + tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False) - elif fn_name == "db_delete_task": - res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper()) - if action_cfg and action_cfg.get("bypass_llm"): - reply_text = action_cfg["success_template"] - if action_cfg.get("follow_up_question"): - reply_text += f"\n\n{action_cfg['follow_up_question']}" - is_eph = action_cfg.get("is_ephemeral", 1) - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) - return reply_text, db_get_chat_history(session_id), { - "type": action_cfg.get("action_type"), - "buttons": action_cfg.get("buttons", []) - } - tool_result_content = json.dumps(res, ensure_ascii=False) + elif fn_name == "db_get_stats": + tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False) - messages.append({"role": "tool", "content": tool_result_content}) + 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) - # --- [SECTION 7: SECONDARY LLM PASS] --- # ANCHOR[SECONDARY_PASS] - second_payload = {"model": TEXT_MODEL, "messages": messages, "stream": False, "options": llm_options} - sec_req = urllib.request.Request(OLLAMA_URL, data=json.dumps(second_payload).encode("utf-8"), headers={"Content-Type": "application/json"}) - with urllib.request.urlopen(sec_req) as sec_response: - sec_res_data = json.loads(sec_response.read().decode("utf-8")) - raw_content = sec_res_data.get("message", {}).get("content", "").strip().replace("**", "") - final_content = clean_raw_tool_tags(clean_output(raw_content)) - db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0) - return final_content, db_get_chat_history(session_id), None + elif fn_name == "db_get_rules": + tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) - # Если вызовов функций не было - raw_str = msg.get("content", "").strip().replace("**", "") - content_str = clean_raw_tool_tags(clean_output(raw_str)) - final_reply = content_str or "Запрос обработан." - db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) - return final_reply, db_get_chat_history(session_id), None + 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}) + + # Интерпретация результатов вызова инструментов + 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)) + + 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 + + db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral) + return final_content, db_get_chat_history(session_id), None + + + # ===================================================================== + # БЛОК 5: ОБЫЧНЫЙ ТЕКСТОВЫЙ ОТВЕТ И СЕМАНТИЧЕСКИЙ TOPIC DRIFT GUARD + # ===================================================================== + raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "") + final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан." + action_payload = None + + # Обработка отвлечений оператора при активной сессии настройки/просмотра + if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW"]: + idle_turns += 1 + logger.info(f"Topic Drift: активна сессия {session_state.get('state_type')}, шагов отвлечения: {idle_turns}/3") + + # Порог отвлечений превышен (> 2 шагов после напоминания) -> Полная зачистка + if idle_turns > 3: + db_clear_session_state(session_id) + deleted_count = db_purge_ephemeral_messages(session_id) + logger.info(f"Topic Drift Guard TTL: сессия закрыта по таймауту, очищено {deleted_count} сообщений.") + action_payload = None + session_state = None + elif idle_turns == 3: + # На 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: + # Шаги 1 и 2: продолжаем обычный диалог, инкрементируя счетчик + 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=(1 if session_state else 0)) + 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 \ No newline at end of file diff --git a/modules/web_api/llm/core/fast_path.py b/modules/web_api/llm/core/fast_path.py new file mode 100644 index 0000000..69b6d7f --- /dev/null +++ b/modules/web_api/llm/core/fast_path.py @@ -0,0 +1,106 @@ +""" +=============================================================================== +FILE: modules/web_api/llm/core/fast_path.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm / core +ROLE: Двухфазный жизненный цикл сессии (PROMPT_PREVIEW -> PROMPT_FOLLOWUP), + атомарная фиксация узлов и мягкая зачистка эфемерного контекста. +=============================================================================== +""" + +import logging +from typing import Dict, Any, Tuple, Optional + +from llm.db_tools import ( + db_apply_prompt_node_action, + db_add_system_prompt, + db_set_session_state, + db_clear_session_state, + db_purge_ephemeral_messages, + db_save_chat_message, + db_get_chat_history +) + +logger = logging.getLogger("FAST_PATH") + + +def handle_fast_path_intercept( + session_id: str, + user_message: str, + full_user_content: str, + session_state: Optional[Dict[str, Any]] +) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]: + """Обрабатывает команды подтверждения, отмены и завершения сессии.""" + if not session_state: + return None + + state_type = session_state.get("state_type") + user_msg_clean = user_message.lower().strip(" .!?:;") + + # ========================================================================= + # ФАЗА 1: ОБРАБОТКА В СОСТОЯНИИ PROMPT_PREVIEW + # ========================================================================= + if state_type == "PROMPT_PREVIEW": + state_data = session_state.get("data_json") or {} + if not isinstance(state_data, dict): + state_data = {} + + draft_text = state_data.get("draft_text", "") + action = state_data.get("action") + sec_id = state_data.get("section_id") + itm_id = state_data.get("item_id") + content = state_data.get("content", "") + + # 1. ПОДТВЕРДИТЬ ИЗМЕНЕНИЯ -> ПЕРЕХОД В PROMPT_FOLLOWUP + if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: + if action == "MANUAL_EDIT" or not action or sec_id is None: + if draft_text: + db_add_system_prompt("main_agent", draft_text) + else: + db_apply_prompt_node_action(action=action, section_id=sec_id, item_id=itm_id, content=content) + + logger.info("Системный промпт применен в БД. Переход в PROMPT_FOLLOWUP.") + + # Переводим сессию в режим ожидания продолжения/завершения + db_set_session_state(session_id, "PROMPT_FOLLOWUP", {}) + + reply_text = "Системный промпт успешно сохранен и применен в базе данных. Желаете продолжить работу с системным промптом или завершить?" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + + action_payload = { + "type": "PROMPT_FOLLOWUP", + "buttons": [ + {"label": "✨ Показать обновленный промпт", "value": "покажи системный промпт", "style": "secondary"}, + {"label": "🏁 Завершить работу", "value": "завершить работу", "style": "primary"} + ] + } + return reply_text, db_get_chat_history(session_id), action_payload + + # 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}") + + reply_text = "Изменения системного промпта отменены. Контекст диалога чист." + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) + return reply_text, db_get_chat_history(session_id), None + + # ========================================================================= + # ФАЗА 2: ОБРАБОТКА В СОСТОЯНИИ PROMPT_FOLLOWUP + # ========================================================================= + elif state_type == "PROMPT_FOLLOWUP": + # Явный сигнал завершения работы + if user_msg_clean in [ + "завершить работу", "завершить", "закончить", "готово", "всё", + "все", "нет", "нет, спасибо", "спасибо", "хватит", "выйти" + ]: + db_clear_session_state(session_id) + deleted_count = db_purge_ephemeral_messages(session_id) + logger.info(f"Сессия работы с промптом закрыта. Очищено эфемерных сообщений: {deleted_count}") + + reply_text = "Работа с системным промптом завершена. Чем могу помочь?" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) + return reply_text, db_get_chat_history(session_id), None + + return None \ No newline at end of file diff --git a/modules/web_api/llm/core/ollama_client.py b/modules/web_api/llm/core/ollama_client.py new file mode 100644 index 0000000..a6fd772 --- /dev/null +++ b/modules/web_api/llm/core/ollama_client.py @@ -0,0 +1,61 @@ +""" +=============================================================================== +FILE: modules/web_api/llm/core/ollama_client.py +ROLE: Транспортный клиент HTTP взаимодействия с локальным API Ollama. +=============================================================================== +""" + +# ANCHOR[OLLAMA_CLIENT_IMPORTS] +import json +import urllib.request +import urllib.error +import logging +from typing import Dict, Any, Optional + +logger = logging.getLogger("OLLAMA_CLIENT") + +OLLAMA_URL = "http://192.168.11.3:11434/api/chat" +TEXT_MODEL = "qwen2.5:14b" +VISION_MODEL = "qwen2.5vl:7b-q8_0" + +LLM_OPTIONS = { + "num_predict": 8192, + "num_ctx": 8192, + "temperature": 0.1, + "repeat_penalty": 1.1, + "presence_penalty": 0.5, + "top_p": 0.9 +} + + +# ANCHOR[OLLAMA_REQUEST_DISPATCHER] +def call_ollama_chat( + messages: list, + tools: Optional[list] = None, + is_vision: bool = False, + timeout: int = 120 +) -> Dict[str, Any]: + """ + Отправляет подготовленный массив сообщений в API Ollama. + Возвращает разобранный словарь сообщения ответа или генерирует исключение. + """ + model_name = VISION_MODEL if is_vision else TEXT_MODEL + payload = { + "model": model_name, + "messages": messages, + "stream": False, + "options": LLM_OPTIONS + } + + if tools and not is_vision: + payload["tools"] = tools + + req = urllib.request.Request( + OLLAMA_URL, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"} + ) + + with urllib.request.urlopen(req, timeout=timeout) as response: + res_data = json.loads(response.read().decode("utf-8")) + return res_data.get("message", {}) \ No newline at end of file diff --git a/modules/web_api/llm/core/prompt_merger.py b/modules/web_api/llm/core/prompt_merger.py new file mode 100644 index 0000000..5407901 --- /dev/null +++ b/modules/web_api/llm/core/prompt_merger.py @@ -0,0 +1,74 @@ +""" +=============================================================================== +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'{line}') + else: + diff_lines.append(line) + + return "\n".join(diff_lines) \ No newline at end of file diff --git a/modules/web_api/llm/core/tool_injector.py b/modules/web_api/llm/core/tool_injector.py index 43ac077..3dd45c3 100644 --- a/modules/web_api/llm/core/tool_injector.py +++ b/modules/web_api/llm/core/tool_injector.py @@ -1,11 +1,20 @@ +""" +=============================================================================== +FILE: modules/web_api/llm/core/tool_injector.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm / core +ROLE: Санитарная очистка сырых артефактов и тегов из ответов LLM. +=============================================================================== +""" import re -import json import logging from typing import List, Dict, Any logger = logging.getLogger("TOOL_INJECTOR") + def clean_raw_tool_tags(text: str) -> str: + """Удаляет сырые теги вызова инструментов, если модель случайно вывела их в текст.""" if not text: return "" text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*()?', '', text) @@ -14,26 +23,28 @@ def clean_raw_tool_tags(text: str) -> str: text = re.sub(r'', '', text) return text.strip() + def clean_output(text: str) -> str: + """Удаляет слова-паразиты и склейки в начале ответа.""" if not text: return text - artifacts = ["почемучто", "почто", "почему что"] - lower_text = text.lower() + + artifacts = [ + "почемучка", "почемучка,", "почемучка!", "почемучка?", + "почемучто", "почто", "почему что", "почему-то", + "здравствуйте!", "привет!" + ] + + cleaned = text.strip() for art in artifacts: - if lower_text.startswith(art): - text = text[len(art):].lstrip(",.!?:; -") - return text.strip() + if cleaned.lower().startswith(art): + cleaned = cleaned[len(art):].lstrip(",.!?:; -") + + 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 '' 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}") + """ + Проходной фильтр: все решения о вызове инструментов принимает исключительно LLM. + """ return tool_calls \ No newline at end of file diff --git a/modules/web_api/llm/db/db_prompts.py b/modules/web_api/llm/db/db_prompts.py index e4de939..ea55934 100644 --- a/modules/web_api/llm/db/db_prompts.py +++ b/modules/web_api/llm/db/db_prompts.py @@ -1,6 +1,13 @@ """ +=============================================================================== FILE: modules/web_api/llm/db/db_prompts.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm / db +ROLE: Реляционное управление системным промптом (таблица system_prompt_nodes), + базой знаний, реестром действий и сессионными стейтами. +=============================================================================== """ +import re import json import logging from typing import List, Dict, Any, Optional @@ -8,16 +15,116 @@ from .connection import get_db_connection logger = logging.getLogger("DB_PROMPTS") -def db_get_active_system_prompt() -> str: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT prompt_text FROM system_prompts WHERE is_active = 1 ORDER BY id DESC LIMIT 1") - row = cursor.fetchone() - conn.close() - return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI." + +def init_prompt_nodes_table(): + """Создает реляционную таблицу узлов промпта и заполняет базовыми данными.""" + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS system_prompt_nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prompt_name TEXT DEFAULT 'main_agent', + section_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + content TEXT NOT NULL, + is_active INTEGER DEFAULT 1, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(prompt_name, section_id, item_id) + ); + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);") + + cursor.execute("SELECT COUNT(*) FROM system_prompt_nodes WHERE prompt_name = 'main_agent'") + if cursor.fetchone()[0] == 0: + seed_nodes = [ + # Раздел 1 + (1, 0, "РОЛЬ И ЗАДАЧИ АССИСТЕНТА"), + (1, 1, "Управление бэклогом задач проекта (через db_get_tasks, db_add_task, db_update_task_status, db_delete_task)."), + (1, 2, "Консультация по правилам и арбитражу кадровых данных/СКУД из базы знаний (через db_get_rules)."), + (1, 3, "Предоставление справки о возможностях и примерах команд (СТРОГО через db_get_reference)."), + (1, 4, "Просмотр аномалий СКУД ⟷ 1С (СТРОГО через db_get_anomalies)."), + (1, 5, "Поддержка диалога с операторами и администраторами системы."), + (1, 7, "Работа с логами, снапшотами и срезами СКУД (через db_get_snapshots)."), + + # Раздел 2 + (2, 0, "ПРАВИЛА ВЫЗОВА ИНСТРУМЕНТОВ И ДАТ"), + (2, 1, "ОБЯЗАТЕЛЬНЫЙ ПРЕВЬЮ-МЕРДЖ: Категорически ЗАПРЕЩЕНО изменять промпт напрямую! При ЛЮБОМ запросе пользователя на изменение системного промпта Ты ОБЯЗАН вызвать инструмент db_prompt_node_edit."), + (2, 2, "БЕЗУСЛОВНОЕ ПОДТВЕРЖДЕНИЕ ПРЕВЬЮ: Инструмент db_confirm_prompt_preview вызывается СТРОГО после того, как пользователь напишет 'подтверждаю', 'да', 'сохраняй'."), + (2, 3, "ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧ: При запросе на удаление задач КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО сразу вызывать db_delete_task! Ты ОБЯЗАН сначала спросить пользователя подтверждение."), + (2, 4, "ИСПОЛЬЗОВАНИЕ КАЛЕНДАРЯ: При любых вопросах про даты БЕРИ ТОЧНУЮ ДАТУ ИЗ [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА] В НАЧАЛЕ КОНТЕКСТА."), + (2, 5, "СТРОГИЙ ВЫЗОВ АНОМАЛИЙ: При запросах аномалий или нарушений Ты ОБЯЗАН вызвать инструмент db_get_anomalies."), + (2, 6, "СТРОГИЙ ВЫЗОВ СПРАВОЧНИКА: При запросах о возможностях, примерах запросов или командах Ты ОБЯЗАН вызывать db_get_reference."), + (2, 7, "Запрещено писать названия функций или код вызова текстом на экран."), + (2, 8, "СТРОГИЙ ВЫЗОВ СНАПШОТОВ: При ЛЮБЫХ запросах про снапшоты, срезы или логи СКУД за дату/день недели Ты ОБЯЗАН вызывать инструмент db_get_snapshots."), + + # Раздел 3 + (3, 0, "ПРАВИЛА СТИЛЯ"), + (3, 1, "Никогда не начинай ответ со склеек или слов-паразитов."), + (3, 2, "Отвечай в чистом текстовом формате (plain text) без спецсимволов или ###, если прямо не попросили."), + (3, 3, "Сохраняй инженерный, лаконичный и профессиональный стиль.") + ] + cursor.executemany(""" + INSERT OR IGNORE INTO system_prompt_nodes (prompt_name, section_id, item_id, content) + VALUES ('main_agent', ?, ?, ?) + """, seed_nodes) + conn.commit() + + +def db_get_active_system_prompt(prompt_name: str = "main_agent") -> str: + """Собирает структурированный текст промпта из реляционной таблицы узлов.""" + init_prompt_nodes_table() + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT section_id, item_id, content + FROM system_prompt_nodes + WHERE prompt_name = ? AND is_active = 1 + ORDER BY section_id ASC, item_id ASC + """, (prompt_name,)) + rows = cursor.fetchall() + + if not rows: + return "Ты — ИИ-ассистент SCUD Orion AI." + + lines = [] + current_section = None + + for sec_id, itm_id, content in rows: + if itm_id == 0: + if current_section is not None: + lines.append("") + lines.append(f"{sec_id}. {content}") + current_section = sec_id + else: + lines.append(f" {sec_id}.{itm_id}. {content}") + + return "\n".join(lines) + + +def db_apply_prompt_node_action(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent"): + """Прямое добавление, изменение или удаление узла в БД.""" + init_prompt_nodes_table() + with get_db_connection() as conn: + cursor = conn.cursor() + action_clean = action.upper() + if action_clean in ["ADD", "UPDATE"]: + cursor.execute(""" + INSERT INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active, updated_at) + VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP) + ON CONFLICT(prompt_name, section_id, item_id) DO UPDATE SET + content = excluded.content, + is_active = 1, + updated_at = CURRENT_TIMESTAMP + """, (prompt_name, section_id, item_id, content)) + elif action_clean == "DELETE": + cursor.execute(""" + DELETE FROM system_prompt_nodes + WHERE prompt_name = ? AND section_id = ? AND item_id = ? + """, (prompt_name, section_id, item_id)) + conn.commit() + def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]: - """Получение шаблона, кнопок и настроек эфемерности инструмента из SQLite.""" conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" @@ -35,28 +142,6 @@ def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]: return res return None -def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: - try: - with get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute("BEGIN IMMEDIATE;") - cursor.execute("SELECT id FROM system_prompts WHERE name = ?", (name,)) - existing = cursor.fetchone() - if existing: - cursor.execute( - "UPDATE system_prompts SET prompt_text = ?, is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE name = ?", - (prompt_text, name) - ) - else: - cursor.execute( - "INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)", - (name, prompt_text) - ) - conn.commit() - return {"status": "success", "message": "Системный промпт успешно обновлен"} - except Exception as e: - logger.error(f"Ошибка при сохранении промпта в БД: {e}") - return {"status": "error", "error": str(e)} def db_get_rules() -> List[Dict[str, Any]]: conn = get_db_connection() @@ -66,9 +151,12 @@ def db_get_rules() -> List[Dict[str, Any]]: conn.close() return [dict(r) for r in rows] -def db_set_session_state(session_id: str, state_type: str, data: str): + +def db_set_session_state(session_id: str, state_type: str, data: Any) -> None: + """Сохраняет состояние сессии в SQLite.""" conn = get_db_connection() cursor = conn.cursor() + payload_str = json.dumps(data, ensure_ascii=False) if isinstance(data, (dict, list)) else (str(data) if data is not None else "") cursor.execute(""" INSERT INTO session_states (session_id, state_type, pending_data, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) @@ -76,37 +164,40 @@ def db_set_session_state(session_id: str, state_type: str, data: str): state_type = excluded.state_type, pending_data = excluded.pending_data, updated_at = CURRENT_TIMESTAMP - """, (session_id, state_type, data)) + """, (session_id, state_type, payload_str)) conn.commit() conn.close() + def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]: conn = get_db_connection() cursor = conn.cursor() - cursor.execute("SELECT state_type, pending_data FROM session_states WHERE session_id = ?", (session_id,)) + cursor.execute("SELECT session_id, state_type, pending_data, updated_at FROM session_states WHERE session_id = ?", (session_id,)) row = cursor.fetchone() conn.close() - return dict(row) if row else None + if not row: + return None + res = dict(row) + raw_data = res.get("pending_data") or "" + try: + res["data_json"] = json.loads(raw_data) if raw_data.strip().startswith(("{", "[")) else None + except Exception: + res["data_json"] = None + return res -def db_clear_session_state(session_id: str): + +def db_clear_session_state(session_id: str) -> None: conn = get_db_connection() cursor = conn.cursor() cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) conn.commit() conn.close() -def db_get_session_states() -> Dict[str, Any]: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT session_id, state_type, updated_at FROM session_states") - rows = cursor.fetchall() - conn.close() - return {"status": "success", "active_sessions": [dict(r) for r in rows]} def db_get_stats() -> Dict[str, Any]: conn = get_db_connection() cursor = conn.cursor() - tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks'] + tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompt_nodes', 'session_states', 'tasks'] stats = {} for t in tables: try: @@ -117,6 +208,7 @@ def db_get_stats() -> Dict[str, Any]: conn.close() return {"status": "success", "tables_stats": stats} + def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]: conn = get_db_connection() cursor = conn.cursor() @@ -132,6 +224,7 @@ def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[s conn.close() return {"status": "success", "count": len(rows), "anomalies": [dict(r) for r in rows]} + def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]: conn = get_db_connection() cursor = conn.cursor() @@ -144,4 +237,49 @@ def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]: cursor.execute(query, params) rows = cursor.fetchall() conn.close() - return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]} \ No newline at end of file + return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]} + +def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: + """Надежный парсер: восстанавливает разделы и пункты с авто-выравниванием отступов.""" + try: + init_prompt_nodes_table() + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM system_prompt_nodes WHERE prompt_name = ?", (name,)) + + current_sec = 1 + current_itm = 0 + + for raw_line in prompt_text.splitlines(): + clean_line = re.sub(r'<[^>]+>', '', raw_line).strip() + if not clean_line: + continue + + # 1. Проверяем подпункт (например: "1.8. Текст", "1.8 Текст", "1. 8 Текст") + sub_match = re.match(r'^(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)$', clean_line) + # 2. Проверяем заголовок раздела (например: "1. РОЛЬ И ЗАДАЧИ", "1 РОЛЬ И ЗАДАЧИ") + sec_match = re.match(r'^(\d+)[\.\s\:\-]+(.*)$', clean_line) + + if sub_match: + current_sec = int(sub_match.group(1)) + current_itm = int(sub_match.group(2)) + content = sub_match.group(3).strip() + elif sec_match and not any(c.islower() for c in sec_match.group(2)[:15]): + # Заголовок раздела (обычно капсом) + current_sec = int(sec_match.group(1)) + current_itm = 0 + content = sec_match.group(2).strip() + else: + current_itm += 1 + content = clean_line + + cursor.execute(""" + INSERT OR REPLACE INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active) + VALUES (?, ?, ?, ?, 1) + """, (name, current_sec, current_itm, content)) + + conn.commit() + return {"status": "success", "message": "Системный промпт успешно сохранен по узлам"} + except Exception as e: + logger.error(f"Ошибка при разборе промпта в узлы: {e}") + return {"status": "error", "error": str(e)} \ No newline at end of file diff --git a/modules/web_api/llm/db_tools.py b/modules/web_api/llm/db_tools.py index 375a726..0eec0d2 100644 --- a/modules/web_api/llm/db_tools.py +++ b/modules/web_api/llm/db_tools.py @@ -9,16 +9,15 @@ from .db.db_snapshots import db_get_snapshots, db_delete_snapshots 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, - db_get_session_states, db_get_stats, db_get_anomalies, - db_get_reference, - db_get_tool_action + db_get_reference ) def db_get_current_server_time(): diff --git a/modules/web_api/llm/schemas.py b/modules/web_api/llm/schemas.py index b30a8dc..386139e 100644 --- a/modules/web_api/llm/schemas.py +++ b/modules/web_api/llm/schemas.py @@ -1,4 +1,39 @@ TOOLS_SCHEMA = [ + { + "type": "function", + "function": { + "name": "db_prompt_node_edit", + "description": ( + "ЕДИНСТВЕННЫЙ инструмент для добавления, изменения или УДАЛЕНИЯ пунктов системного промпта. " + "Вызывай ВСЕГДА при фразах 'удали X.Y', 'удалить пункт X.Y', 'добавь X.Y', 'измени X.Y'. " + "При удалении: action='DELETE', section_id=X, item_id=Y, content=''. " + "Запрещено отвечать текстовым подтверждением без вызова этой функции!" + ), + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["ADD", "UPDATE", "DELETE"], + "description": "ADD (добавить), UPDATE (изменить), DELETE (удалить)" + }, + "section_id": { + "type": "integer", + "description": "Номер раздела (например: 1, 2, 3)" + }, + "item_id": { + "type": "integer", + "description": "Номер пункта (например: 4, 8, 9)" + }, + "content": { + "type": "string", + "description": "Текст пункта (для DELETE передается пустая строка)" + } + }, + "required": ["action", "section_id", "item_id"] + } + } + }, { "type": "function", "function": { @@ -82,18 +117,18 @@ TOOLS_SCHEMA = [ } }, { - "type": "function", - "function": { - "name": "db_delete_snapshots", - "description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.", - "parameters": { - "type": "object", - "properties": { - "snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"}, - "day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"} - } + "type": "function", + "function": { + "name": "db_delete_snapshots", + "description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.", + "parameters": { + "type": "object", + "properties": { + "snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"}, + "day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"} } } + } }, { "type": "function", @@ -169,53 +204,5 @@ TOOLS_SCHEMA = [ } } } - }, - { - "type": "function", - "function": { - "name": "db_add_system_prompt", - "description": "Прямое сохранение системного промпта в БД без предварительного просмотра.", - "parameters": { - "type": "object", - "properties": { - "name": {"type": "string", "description": "Имя промпта, по умолчанию main_agent"}, - "prompt_text": {"type": "string", "description": "Полный текст системного промпта"} - }, - "required": ["prompt_text"] - } - } - }, - { - "type": "function", - "function": { - "name": "db_preview_prompt_merge", - "description": "Сформировать предпросмотр изменения системного промпта. Вызывается при любых запросах на добавление ('добавь пункт...'), изменение или удаление пунктов системного промпта ('удали пункт...').", - "parameters": { - "type": "object", - "properties": { - "prompt_text": { - "type": "string", - "description": "Полный текст добавляемого пункта или номер/описание удаляемого пункта." - } - }, - "required": ["prompt_text"] - } - } - }, - { - "type": "function", - "function": { - "name": "db_confirm_prompt_preview", - "description": "Подтвердить и сохранить текущее подготовленное превью в БД. Вызывай этот инструмент, когда пользователь говорит 'подтверждаю', 'да', 'вноси', 'применяй', 'сохраняй' или одобряет превью в любой форме.", - "parameters": {"type": "object", "properties": {}} - } - }, - { - "type": "function", - "function": { - "name": "db_cancel_prompt_preview", - "description": "Отменить текущее превью системного промпта и сбросить изменения. Вызывай, когда пользователь явно отказывается от изменений.", - "parameters": {"type": "object", "properties": {}} - } - } + } ] \ No newline at end of file diff --git a/modules/web_api/main.py b/modules/web_api/main.py index 4f66862..1a64668 100644 --- a/modules/web_api/main.py +++ b/modules/web_api/main.py @@ -2,68 +2,37 @@ =============================================================================== FILE: modules/web_api/main.py PROJECT: SCUD Orion AI (Unified Repository) -MODULE: web_api (FastAPI REST Server & Context Management) -ROLE: Главный шлюз веб-интерфейса, авторизация пользователей (JWT/Bcrypt), - маршрутизация диалогов с LLM, OCR-парсинг файлов и управление задачами. - -AI-CONTEXT-ANCHORS & INVARIANTS: - - ANCHOR[SYS_PATH]: Добавляет директорию модуля в sys.path для корректных импортов - независимо от рабочей директории запуска (root или web_api). - - ANCHOR[STATIC_MOUNT]: Рассчитывает абсолютный путь к папке static/ для надежного - рендеринга интерфейса и ассетов (css/js/favicon). - - ANCHOR[AUTH_JWT]: Изолирует персональные пространства задач по user_id (sub). - - ANCHOR[CHAT_PIPELINE]: Оркестрирует пайплайн парсинга вложений (file_parser) и - генерации ответов LLM (agent.process_chat_message). - -DEPENDENCIES: - - modules/web_api/llm/agent.py (process_chat_message) - - modules/web_api/llm/db_tools.py (db_get_tasks, DB_PATH) - - modules/web_api/llm/file_parser.py (extract_text_from_file) +MODULE: web_api (Main Application Entry Point) +ROLE: Инициализация FastAPI приложения, подключение роутеров и статики. =============================================================================== """ -# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_PATH] +# ANCHOR[APP_INIT_IMPORTS] import os import sys -import json -import sqlite3 import logging -import urllib.request -from datetime import datetime, timedelta -from typing import List, Dict, Any, Optional -# Гарантируем корректный импорт подмодулей web_api независимо от точки запуска CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) if CURRENT_DIR not in sys.path: sys.path.insert(0, CURRENT_DIR) -import jwt -from passlib.context import CryptContext -from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Form -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi import FastAPI, HTTPException from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse from fastapi.exceptions import RequestValidationError -from pydantic import BaseModel -# Внутренние модули LLM и БД -from llm.agent import process_chat_message -from llm.db_tools import db_get_tasks, DB_PATH -from llm.file_parser import extract_text_from_file +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 -# --- [SECTION 2: CONFIGURATION & SECURITY] --- # ANCHOR[AUTH_CONFIG] +# ANCHOR[APP_CONFIG] logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[logging.StreamHandler()] ) -JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure" -ALGORITHM = "HS256" - -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -security = HTTPBearer() - STATIC_DIR = os.path.join(CURRENT_DIR, "static") app = FastAPI(title="SCUD Orion AI Context API", version="2.5") @@ -79,57 +48,15 @@ async def validation_exception_handler(request, exc): content={"detail": exc.errors(), "body": str(exc)} ) -# --- [SECTION 3: DATABASE & TOKEN HELPERS] --- # ANCHOR[DB_HELPERS] -def get_db(): - """Создает безопасное соединение с SQLite БД модуля.""" - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - return conn +# ANCHOR[ROUTER_REGISTRATION] +app.include_router(auth_router) +app.include_router(admin_router) +app.include_router(tasks_router) +app.include_router(chat_router) -def create_access_token(user_id: int, username: str, is_admin: bool) -> str: - payload = { - "sub": str(user_id), - "username": username, - "is_admin": is_admin, - "exp": datetime.utcnow() + timedelta(days=30) - } - return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) - -def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: - try: - token = credentials.credentials - payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) - user_id = int(payload.get("sub")) - username = payload.get("username") - is_admin = bool(payload.get("is_admin", False)) - return {"id": user_id, "username": username, "is_admin": is_admin} - except Exception as e: - logging.warning(f"Auth error: {e}") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Недействительный или просроченный токен авторизации", - headers={"WWW-Authenticate": "Bearer"}, - ) - -# Pydantic-схемы валидации запросов -class AuthRequest(BaseModel): - username: str - password: str - -class CreateUserRequest(BaseModel): - username: str - password: str - full_name: Optional[str] = None - is_admin: Optional[bool] = False - -class ChangePasswordRequest(BaseModel): - old_password: str - new_password: str - -# --- [SECTION 4: STATIC FILES & SPA ROUTES] --- # ANCHOR[STATIC_MOUNT] +# ANCHOR[ROOT_STATIC_ROUTES] @app.get("/") def read_root(): - """Отдает главную страницу панели управления.""" index_path = os.path.join(STATIC_DIR, "index.html") if os.path.exists(index_path): return FileResponse(index_path) @@ -142,175 +69,24 @@ async def favicon(): return FileResponse(file_path) raise HTTPException(status_code=404) -# --- [SECTION 5: AUTHENTICATION & USER MANAGEMENT] --- # ANCHOR[AUTH_JWT] -@app.post("/api/v1/auth/login") -def login(req: AuthRequest): - username = req.username.strip().lower() - logging.info(f"===> Попытка входа для пользователя: {username}") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,)) - user = cursor.fetchone() - conn.close() - - if not user or not pwd_context.verify(req.password, user["password_hash"]): - logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}") - raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") - - is_admin = bool(user["is_admin"]) or (user["username"] == "puh") - token = create_access_token(user["id"], user["username"], is_admin) - logging.info(f"===> УСПЕХ: Авторизован пользователь {username}") - - return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin} - -@app.post("/api/v1/auth/change-password") -def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)): - if not req.new_password or len(req.new_password) < 4: - raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],)) - user = cursor.fetchone() - - if not user or not pwd_context.verify(req.old_password, user["password_hash"]): - conn.close() - raise HTTPException(status_code=400, detail="Неверный старый пароль") - - new_hash = pwd_context.hash(req.new_password) - cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"])) - conn.commit() - conn.close() - - logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}") - return {"status": "success", "message": "Пароль успешно изменен"} - -@app.get("/api/v1/admin/users") -def list_users(current_user: Dict[str, Any] = Depends(get_current_user)): - if not current_user["is_admin"]: - raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC") - users = [dict(r) for r in cursor.fetchall()] - conn.close() - return users - -@app.post("/api/v1/admin/users") -def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)): - if not current_user["is_admin"]: - raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") - - username = req.username.strip().lower() - if not username or not req.password: - raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT id FROM users WHERE username = ?", (username,)) - if cursor.fetchone(): - conn.close() - raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует") - - pwd_hash = pwd_context.hash(req.password) - full_name = req.full_name.strip() if req.full_name else None - is_admin = 1 if req.is_admin else 0 - - cursor.execute( - "INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)", - (username, pwd_hash, full_name, is_admin) - ) - conn.commit() - conn.close() - - logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}") - return {"status": "success", "message": f"Пользователь {username} создан"} - -@app.delete("/api/v1/admin/users/{user_id}") -def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)): - if not current_user["is_admin"]: - raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") - - if user_id == current_user["id"]: - raise HTTPException(status_code=400, detail="Нельзя удалить самого себя") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) - conn.commit() - conn.close() - - logging.info(f"Удален пользователь ID: {user_id}") - return {"status": "success", "message": "Пользователь удален"} - -# --- [SECTION 6: TASK TRACKER & LLM CHAT PIPELINE] --- # ANCHOR[CHAT_PIPELINE] -@app.get("/api/v1/tasks") -def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): - """Получить задачи текущего авторизованного пользователя.""" - return db_get_tasks(user_id=user["id"]) - -# --- ЧАТ С АВТОРИЗАЦИЕЙ --- -@app.post("/api/v1/chat") -async def chat_endpoint( - session_id: str = Form("web_session_main"), - message: str = Form(""), - file: Optional[UploadFile] = File(default=None), - current_user: dict = Depends(get_current_user) -): - parsed_file = {"text": "", "image_b64": None} - if file and file.filename: - file_bytes = await file.read() - parsed_file = extract_text_from_file(file_bytes, file.filename) - - reply, history, action_type = process_chat_message( - user_id=current_user["id"], - user_message=message, - file_context=parsed_file["text"], - image_b64=parsed_file["image_b64"], - session_id=session_id - ) - return {"reply": reply, "history": history, "action_type": action_type} - - -# --- ГОСТЕВОЙ ЧАТ --- -@app.post("/api/v1/chat/guest") -async def guest_chat_endpoint( - session_id: str = Form("web_session_main"), - message: str = Form(""), - file: Optional[UploadFile] = File(default=None) -): - parsed_file = {"text": "", "image_b64": None} - if file and file.filename: - file_bytes = await file.read() - parsed_file = extract_text_from_file(file_bytes, file.filename) - - reply, history, action_type = process_chat_message( - user_id=0, - user_message=message, - file_context=parsed_file["text"], - image_b64=parsed_file["image_b64"], - session_id=session_id - ) - return {"reply": reply, "history": history, "action_type": action_type} - -# --- [SECTION 7: STATIC FALLBACK ROUTER] --- # ANCHOR[STATIC_FALLBACK] @app.get("/{file_path:path}") def serve_static_fallback(file_path: str): clean_path = file_path.lstrip("/") - target = os.path.join(STATIC_DIR, clean_path) + if os.path.isfile(target): + if clean_path.endswith(".js"): + return FileResponse(target, media_type="application/javascript") + elif clean_path.endswith(".css"): + return FileResponse(target, media_type="text/css") return FileResponse(target) + # Резервный рекурсивный поиск файла в static filename = os.path.basename(clean_path) - target_js = os.path.join(STATIC_DIR, "js", filename) - if filename.endswith(".js") and os.path.isfile(target_js): - return FileResponse(target_js, media_type="application/javascript") - - target_css = os.path.join(STATIC_DIR, "css", filename) - if filename.endswith(".css") and os.path.isfile(target_css): - return FileResponse(target_css, media_type="text/css") + for root, _, files in os.walk(STATIC_DIR): + if filename in files: + full_path = os.path.join(root, filename) + media = "application/javascript" if filename.endswith(".js") else "text/css" + return FileResponse(full_path, media_type=media) raise HTTPException(status_code=404, detail="File not found") \ No newline at end of file diff --git a/modules/web_api/routers/admin.py b/modules/web_api/routers/admin.py new file mode 100644 index 0000000..e55aeca --- /dev/null +++ b/modules/web_api/routers/admin.py @@ -0,0 +1,83 @@ +""" +=============================================================================== +FILE: modules/web_api/routers/admin.py +ROLE: Администрирование пользователей и прав доступа. +=============================================================================== +""" + +# ANCHOR[ADMIN_ROUTER_IMPORTS] +import logging +from typing import Dict, Any, Optional +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from .auth import get_current_user, get_db, pwd_context + +router = APIRouter(prefix="/api/v1/admin", tags=["admin"]) + +# ANCHOR[ADMIN_SCHEMAS] +class CreateUserRequest(BaseModel): + username: str + password: str + full_name: Optional[str] = None + is_admin: Optional[bool] = False + +# ANCHOR[ADMIN_ENDPOINTS] +@router.get("/users") +def list_users(current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC") + users = [dict(r) for r in cursor.fetchall()] + conn.close() + return users + +@router.post("/users") +def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + username = req.username.strip().lower() + if not username or not req.password: + raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT id FROM users WHERE username = ?", (username,)) + if cursor.fetchone(): + conn.close() + raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует") + + pwd_hash = pwd_context.hash(req.password) + full_name = req.full_name.strip() if req.full_name else None + is_admin = 1 if req.is_admin else 0 + + cursor.execute( + "INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)", + (username, pwd_hash, full_name, is_admin) + ) + conn.commit() + conn.close() + + logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}") + return {"status": "success", "message": f"Пользователь {username} создан"} + +@router.delete("/users/{user_id}") +def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + if user_id == current_user["id"]: + raise HTTPException(status_code=400, detail="Нельзя удалить самого себя") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) + conn.commit() + conn.close() + + logging.info(f"Удален пользователь ID: {user_id}") + return {"status": "success", "message": "Пользователь удален"} \ No newline at end of file diff --git a/modules/web_api/routers/auth.py b/modules/web_api/routers/auth.py new file mode 100644 index 0000000..73ba0c4 --- /dev/null +++ b/modules/web_api/routers/auth.py @@ -0,0 +1,112 @@ +""" +=============================================================================== +FILE: modules/web_api/routers/auth.py +ROLE: Аутентификация, валидация JWT-токенов и управление паролями. +=============================================================================== +""" + +# ANCHOR[AUTH_ROUTER_IMPORTS] +import sqlite3 +import logging +from datetime import datetime, timedelta +from typing import Dict, Any, Optional + +import jwt +from passlib.context import CryptContext +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from pydantic import BaseModel + +from llm.db_tools import DB_PATH + +JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure" +ALGORITHM = "HS256" + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +security = HTTPBearer() + +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) + +# ANCHOR[AUTH_DB_HELPERS] +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + +def create_access_token(user_id: int, username: str, is_admin: bool) -> str: + payload = { + "sub": str(user_id), + "username": username, + "is_admin": is_admin, + "exp": datetime.utcnow() + timedelta(days=30) + } + return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) + +def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: + try: + token = credentials.credentials + payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) + user_id = int(payload.get("sub")) + username = payload.get("username") + is_admin = bool(payload.get("is_admin", False)) + return {"id": user_id, "username": username, "is_admin": is_admin} + except Exception as e: + logging.warning(f"Auth error: {e}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Недействительный или просроченный токен авторизации", + headers={"WWW-Authenticate": "Bearer"}, + ) + +# 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,)) + user = cursor.fetchone() + conn.close() + + if not user or not pwd_context.verify(req.password, user["password_hash"]): + logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}") + raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") + + is_admin = bool(user["is_admin"]) or (user["username"] == "puh") + token = create_access_token(user["id"], user["username"], is_admin) + logging.info(f"===> УСПЕХ: Авторизован пользователь {username}") + + return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin} + +@router.post("/change-password") +def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)): + if not req.new_password or len(req.new_password) < 4: + raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],)) + user = cursor.fetchone() + + if not user or not pwd_context.verify(req.old_password, user["password_hash"]): + conn.close() + raise HTTPException(status_code=400, detail="Неверный старый пароль") + + new_hash = pwd_context.hash(req.new_password) + cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"])) + conn.commit() + conn.close() + + logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}") + return {"status": "success", "message": "Пароль успешно изменен"} \ No newline at end of file diff --git a/modules/web_api/routers/chat.py b/modules/web_api/routers/chat.py new file mode 100644 index 0000000..1dce55e --- /dev/null +++ b/modules/web_api/routers/chat.py @@ -0,0 +1,93 @@ +""" +=============================================================================== +FILE: modules/web_api/routers/chat.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / routers +ROLE: Маршрутизация диалогов с LLM и эндпоинт сохранения онлайн-черновиков. +=============================================================================== +""" + +# ANCHOR[CHAT_ROUTER_IMPORTS] +from typing import Optional, Dict, Any +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 llm.file_parser import extract_text_from_file +from llm.db_tools import db_set_session_state, db_get_session_state + +router = APIRouter(prefix="/api/v1/chat", tags=["chat"]) + + +# ANCHOR[DRAFT_SCHEMA] +class UpdateDraftRequest(BaseModel): + session_id: str + draft_text: str + + +# ANCHOR[CHAT_ENDPOINTS] +@router.post("") +async def chat_endpoint( + session_id: str = Form("web_session_main"), + message: str = Form(""), + file: Optional[UploadFile] = File(default=None), + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Диалог авторизованного пользователя с агентом.""" + parsed_file = {"text": "", "image_b64": None} + if file and file.filename: + file_bytes = await file.read() + parsed_file = extract_text_from_file(file_bytes, file.filename) + + reply, history, action_type = process_chat_message( + user_id=current_user["id"], + user_message=message, + file_context=parsed_file["text"], + image_b64=parsed_file["image_b64"], + session_id=session_id + ) + return {"reply": reply, "history": history, "action_type": action_type} + + +@router.post("/guest") +async def guest_chat_endpoint( + session_id: str = Form("web_session_main"), + message: str = Form(""), + file: Optional[UploadFile] = File(default=None) +): + """Гостевой диалог (user_id=0).""" + parsed_file = {"text": "", "image_b64": None} + if file and file.filename: + file_bytes = await file.read() + parsed_file = extract_text_from_file(file_bytes, file.filename) + + reply, history, action_type = process_chat_message( + user_id=0, + user_message=message, + file_context=parsed_file["text"], + image_b64=parsed_file["image_b64"], + session_id=session_id + ) + return {"reply": reply, "history": history, "action_type": action_type} + + +@router.post("/draft") +def update_draft_endpoint( + req: UpdateDraftRequest, + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Обновляет черновик системного промпта напрямую из интерактивной онлайн-формы.""" + state = db_get_session_state(req.session_id) + if not state or state.get("state_type") != "PROMPT_PREVIEW": + raise HTTPException(status_code=400, detail="Нет активного превью для редактирования") + + # Сохраняем чистый текст с пометкой 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": "Черновик успешно обновлен в сессии"} \ No newline at end of file diff --git a/modules/web_api/routers/tasks.py b/modules/web_api/routers/tasks.py new file mode 100644 index 0000000..5260cfb --- /dev/null +++ b/modules/web_api/routers/tasks.py @@ -0,0 +1,71 @@ +""" +=============================================================================== +FILE: modules/web_api/routers/tasks.py +ROLE: REST API управления задачами (GET / POST / PATCH / DELETE). +=============================================================================== +""" + +# ANCHOR[TASKS_ROUTER_IMPORTS] +from typing import Dict, Any, Optional +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from .auth import get_current_user +from llm.db_tools import ( + db_get_tasks, + db_add_task, + db_update_task_status, + db_delete_task +) + +router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"]) + +# ANCHOR[TASKS_SCHEMAS] +class CreateTaskRequest(BaseModel): + title: str + priority: Optional[str] = "MEDIUM" + module: Optional[str] = "general" + due_date: Optional[str] = None + +class UpdateTaskRequest(BaseModel): + status: Optional[str] = "COMPLETED" + due_date: Optional[str] = None + +# ANCHOR[TASKS_ENDPOINTS] +@router.get("") +def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): + """Получить список всех задач текущего авторизованного пользователя.""" + return db_get_tasks(user_id=user["id"]) + +@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 + ) + if "error" in res: + raise HTTPException(status_code=404, 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) + if "error" in res: + raise HTTPException(status_code=404, detail=res["error"]) + return res \ No newline at end of file diff --git a/modules/web_api/scripts/diagnostics/inspect_db.py b/modules/web_api/scripts/diagnostics/inspect_db.py deleted file mode 100644 index 4f7a02f..0000000 --- a/modules/web_api/scripts/diagnostics/inspect_db.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/modules/web_api/scripts/diagnostics/inspect_files.py b/modules/web_api/scripts/diagnostics/inspect_files.py deleted file mode 100644 index fa3a4f3..0000000 --- a/modules/web_api/scripts/diagnostics/inspect_files.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/modules/web_api/scripts/diagnostics/make_code_snapshot.py b/modules/web_api/scripts/diagnostics/make_code_snapshot.py deleted file mode 100644 index 066bb1e..0000000 --- a/modules/web_api/scripts/diagnostics/make_code_snapshot.py +++ /dev/null @@ -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)") \ No newline at end of file diff --git a/modules/web_api/static/index.html b/modules/web_api/static/index.html index a39b46d..f537f33 100644 --- a/modules/web_api/static/index.html +++ b/modules/web_api/static/index.html @@ -10,7 +10,7 @@ - +
@@ -56,7 +56,7 @@
- +

${t.title || t.description || ''}

@@ -142,4 +161,4 @@ function renderTasks() {
`; }).join(""); -} \ No newline at end of file +} \ No newline at end of file diff --git a/project_code_snapshot.md b/project_code_snapshot.md index 71c9bbe..ffa9679 100644 --- a/project_code_snapshot.md +++ b/project_code_snapshot.md @@ -1,4 +1,4 @@ -# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_orion_ai_v2 +# 📦 ОПТИМИЗИРОВАННЫЙ СЛЕПОК ИСХОДНОГО КОДА scud_orion_ai ## File: `./config.py` ```py @@ -741,6 +741,31 @@ from core.database import ( DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db") +def print_tool_actions(): + """Выводит реестр декларативных действий инструментов и шаблоны кнопок.""" + print("\n" + "=" * 110) + print("🧰 ДЕКЛАРАТИВНЫЙ РЕЕСТР ДЕЙСТВИЙ ИНСТРУМЕНТОВ (tool_action_registry):") + print("=" * 110) + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT id, tool_name, category, bypass_llm, success_template, follow_up_question, buttons_json + FROM tool_action_registry + WHERE is_active = 1 + ORDER BY id ASC + """) + rows = cursor.fetchall() + if not rows: + print("Таблица tool_action_registry пуста.") + else: + for r in rows: + print(f"ID: {r[0]} | Tool: [{r[1]}] | Категория: {r[2]} | Bypass LLM: {'ДА (0.05с)' if r[3] else 'НЕТ'}") + print(f" • Сообщение: {r[4]}") + if r[5]: + print(f" • Вопрос: {r[5]}") + print(f" • Кнопки: {r[6]}") + print("-" * 110) + print("=" * 110 + "\n") def print_stats(): """Выводит общую статистику по записям в таблицах БД.""" @@ -749,11 +774,14 @@ 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'] + tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states'] for t in tables: - cursor.execute(f"SELECT COUNT(*) FROM {t}") - cnt = cursor.fetchone()[0] - print(f" • Таблица [{t:<18}]: {cnt:>6} записей") + try: + cursor.execute(f"SELECT COUNT(*) FROM {t}") + cnt = cursor.fetchone()[0] + print(f" • Таблица [{t:<18}]: {cnt:>6} записей") + except Exception: + pass print("=" * 60 + "\n") @@ -934,28 +962,35 @@ def dump_all_to_excel(out_filename="db_dump_full.xlsx"): out_path = os.path.join(OUTPUT_DIR, out_filename) print(f"\n[🔄] Создание полного дампа БД в файл: {out_path} ...") with get_connection() as conn, pd.ExcelWriter(out_path, engine='openpyxl') as writer: - for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base']: - df = pd.read_sql_query(f"SELECT * FROM {table}", conn) - df.to_excel(writer, sheet_name=table[:31], index=False) + for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks']: + try: + df = pd.read_sql_query(f"SELECT * FROM {table}", conn) + df.to_excel(writer, sheet_name=table[:31], index=False) + except Exception: + pass print(f"[✓] Дамп успешно сохранен: {out_path}\n") def print_system_prompts(): """Выводит все системные промпты из базы данных.""" print("\n" + "=" * 80) - print("📝 СИСТЕМНЫЕ ПРОМПТЫ (system_prompts):") + print("📝 СИСТЕМНЫЕ ПРОМПТЫ (system_prompt_nodes):") print("=" * 80) with get_connection() as conn: cursor = conn.cursor() - cursor.execute("SELECT id, name, is_active, updated_at, prompt_text FROM system_prompts ORDER BY id DESC") - rows = cursor.fetchall() + try: + cursor.execute("SELECT section_id, item_id, content, is_active, updated_at FROM system_prompt_nodes ORDER BY section_id ASC, item_id ASC") + rows = cursor.fetchall() + except Exception: + rows = [] + if not rows: - print("Таблица system_prompts пуста.") + print("Таблица system_prompt_nodes пуста.") else: for r in rows: - print(f"ID: {r[0]} | Name: {r[1]} | Active: {r[2]} | Updated: {r[3]}") + print(f"Раздел {r[0]}.{r[1]} | Active: {r[3]} | Updated: {r[4]}") + print(f" {r[2]}") print("-" * 80) - print(f"{r[4]}\n") print("=" * 80 + "\n") @@ -978,6 +1013,50 @@ def print_session_states(): print("=" * 80 + "\n") +def print_chat_messages(session_id=None, limit=50): + """Выводит таблицу истории сообщений чата (контекст).""" + print("\n" + "=" * 105) + print(f"💬 ИСТОРИЯ СООБЩЕНИЙ ЧАТА (chat_messages) {f'для сессии: {session_id}' if session_id else 'все сессии'}:") + print("=" * 105) + + with get_connection() as conn: + cursor = conn.cursor() + if session_id: + cursor.execute(""" + SELECT id, session_id, role, content, is_ephemeral, created_at + FROM chat_messages + WHERE session_id = ? + ORDER BY id ASC + LIMIT ? + """, (session_id, limit)) + else: + cursor.execute(""" + SELECT id, session_id, role, content, is_ephemeral, created_at + FROM chat_messages + ORDER BY id DESC + LIMIT ? + """, (limit,)) + + rows = cursor.fetchall() + if not rows: + print("Таблица chat_messages пуста.") + else: + # Если выводили все без конкретной сессии в порядке DESC, развернем для читаемости + if not session_id: + rows = list(reversed(rows)) + + for r in rows: + msg_id, sess, role, content, ephemeral, created = r + eph_marker = " [ЭФЕМЕРНОЕ]" if ephemeral else "" + print(f"[{msg_id}] {created} | Сессия: {sess} | Роль: {role.upper()}{eph_marker}") + print("-" * 105) + # Выводим контент, ограничивая длинные сообщения при необходимости + content_preview = content if content else "" + print(f"{content_preview}") + print("=" * 105) + print("\n") + + HELP_TEXT = """ CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db) @@ -988,8 +1067,10 @@ CLI-утилита инспекции и управления SQLite базой absences [ДД.ММ.ГГГГ] -- Посмотреть список официально отсутствующих из 1С:ЗУП anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С rules -- Посмотреть правила Базы Знаний ИИ из SQLite - prompts -- Посмотреть системные промпты (system_prompts) + prompts -- Посмотреть узлы системного промпта (system_prompt_nodes) + tools -- Посмотреть реестр действий инструментов и кнопок (tool_action_registry) sessions -- Посмотреть активные сессии и превью (session_states) + context [session_id] [--limit N] -- Посмотреть таблицу контекста сообщений чата (chat_messages) dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшота по ID или всех за выбранный день @@ -997,12 +1078,12 @@ CLI-утилита инспекции и управления SQLite базой python scripts/db_cli.py stats python scripts/db_cli.py snapshots 06.08.2026 python scripts/db_cli.py scud 06.08.2026 --export-xlsx срез_четверг - python scripts/db_cli.py scud --snapshot Y20260805-007 python scripts/db_cli.py absences 07.08.2026 python scripts/db_cli.py prompts + 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 snapshot del Y20260805-007 - python scripts/db_cli.py snapshot del --day 04.08.2026 python scripts/db_cli.py dump my_dump.xlsx """ @@ -1017,12 +1098,13 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, add_help=False ) - parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot'], help="Основная команда") - parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del')") + 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('--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)") if len(sys.argv) == 1: print_stats() @@ -1048,6 +1130,11 @@ def main(): print_system_prompts() elif args.command == 'sessions': print_session_states() + 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) elif args.command == 'dump': filename = args.param if args.param else "db_dump_full.xlsx" dump_all_to_excel(filename) @@ -1165,6 +1252,115 @@ if __name__ == "__main__": fix_snapshots() ``` +## File: `./scripts/init_tool_registry.py` +```py +""" +Скрипт инициализации таблицы tool_action_registry в SQLite БД. +""" +import os +import sys +import json +import sqlite3 + +BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +DB_PATH = os.path.join(BASE_DIR, "data", "scud_orion_ai.db") + +def init_tool_action_registry(): + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # 1. Создаем таблицу + cursor.execute(""" + CREATE TABLE IF NOT EXISTS tool_action_registry ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tool_name TEXT UNIQUE NOT NULL, + category TEXT DEFAULT 'general', + bypass_llm INTEGER DEFAULT 1, + success_template TEXT NOT NULL, + follow_up_question TEXT, + action_type TEXT DEFAULT 'BUTTONS', + buttons_json TEXT, + is_active INTEGER DEFAULT 1, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + # 2. Начальное наполнение базовыми сценариями + seed_tools = [ + ( + "db_confirm_prompt_preview", + "prompt", + 1, + "✅ Системный промпт успешно сохранен и применен в базе данных.", + "Желаете продолжить работу с системным промптом?", + "FOLLOW_UP_ACTION", + json.dumps([ + {"label": "Показать промпт", "value": "покажи системный промпт"}, + {"label": "Готово", "value": "нет, спасибо"} + ], ensure_ascii=False) + ), + ( + "db_cancel_prompt_preview", + "prompt", + 1, + "❌ Изменения системного промпта отменены.", + "Желаете продолжить работу с системным промптом?", + "FOLLOW_UP_ACTION", + json.dumps([ + {"label": "Показать промпт", "value": "покажи системный промпт"}, + {"label": "Готово", "value": "нет, спасибо"} + ], ensure_ascii=False) + ), + ( + "db_delete_task", + "tasks", + 1, + "🗑 Задача успешно удалена из вашего реестра.", + "Хотите посмотреть обновленный список задач?", + "FOLLOW_UP_ACTION", + json.dumps([ + {"label": "Показать задачи", "value": "покажи список задач"}, + {"label": "Готово", "value": "нет"} + ], ensure_ascii=False) + ), + ( + "db_delete_snapshots", + "snapshots", + 1, + "🗑 Выбранные снапшоты успешно удалены из базы данных.", + "Вывести актуальный список снапшотов?", + "FOLLOW_UP_ACTION", + json.dumps([ + {"label": "Показать снапшоты", "value": "покажи список снапшотов"}, + {"label": "Готово", "value": "нет"} + ], ensure_ascii=False) + ) + ] + + for item in seed_tools: + cursor.execute(""" + INSERT INTO tool_action_registry ( + tool_name, category, bypass_llm, success_template, follow_up_question, action_type, buttons_json + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(tool_name) DO UPDATE SET + category = excluded.category, + bypass_llm = excluded.bypass_llm, + success_template = excluded.success_template, + follow_up_question = excluded.follow_up_question, + action_type = excluded.action_type, + buttons_json = excluded.buttons_json, + updated_at = CURRENT_TIMESTAMP + """, item) + + conn.commit() + conn.close() + print("[✓] Таблица tool_action_registry успешно создана и наполнена!") + +if __name__ == "__main__": + init_tool_action_registry() + +``` + ## File: `./scripts/diagnostics/inspect_db.py` ```py import os @@ -1216,6 +1412,68 @@ conn.close() print("=" * 80) ``` +## File: `./scripts/diagnostics/inspect_ephemeral.py` +```py +import os +import sqlite3 + +DB_PATH = os.path.join(os.path.dirname(__file__), "../../data/scud_orion_ai.db") +if not os.path.exists(DB_PATH): + DB_PATH = "data/scud_orion_ai.db" + +conn = sqlite3.connect(DB_PATH) +cursor = conn.cursor() + +print("=" * 90) +print(f"🔍 ИНСПЕКЦИЯ СООБЩЕНИЙ В БАЗЕ: {DB_PATH}") +print("=" * 90) + +# 1. Проверяем наличие колонки is_ephemeral +cursor.execute("PRAGMA table_info(chat_messages);") +cols = [c[1] for c in cursor.fetchall()] + +if "is_ephemeral" not in cols: + print("❌ Колонка is_ephemeral отсутствует в таблице chat_messages!") + conn.close() + exit(1) + +# 2. Считаем общее количество и распределение +cursor.execute("SELECT COUNT(*) FROM chat_messages") +total_count = cursor.fetchone()[0] + +cursor.execute("SELECT COUNT(*) FROM chat_messages WHERE is_ephemeral = 1") +ephemeral_count = cursor.fetchone()[0] + +cursor.execute("SELECT COUNT(*) FROM chat_messages WHERE is_ephemeral = 0") +permanent_count = cursor.fetchone()[0] + +print(f"Всего записей в истории: {total_count}") +print(f" • Постоянные (is_ephemeral = 0): {permanent_count}") +print(f" • Фантомные / Эфемерные (is_ephemeral = 1): {ephemeral_count}") +print("-" * 90) + +# 3. Выводим все фантомные записи +if ephemeral_count == 0: + print("В базе нет фантомных записей (is_ephemeral = 1 пуст).") +else: + print(f"📋 СПИСОК ВСЕХ ФАНТОМНЫХ СООБЩЕНИЙ ({ephemeral_count} шт.):") + cursor.execute(""" + SELECT id, session_id, role, created_at, content + FROM chat_messages + WHERE is_ephemeral = 1 + ORDER BY id ASC + """) + for row in cursor.fetchall(): + msg_id, session_id, role, created_at, content = row + print(f"\n[ID: {msg_id}] | Сессия: {session_id} | Роль: {role} | Время: {created_at}") + print("Содержимое:") + print(content.strip()) + print("-" * 70) + +conn.close() +print("=" * 90) +``` + ## File: `./scripts/diagnostics/inspect_files.py` ```py import os @@ -1249,15 +1507,29 @@ import os OUTPUT_SNAPSHOT = "project_code_snapshot.md" -# Расширения файлов для включения в снимок -ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini'} -EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} -EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_orion_ai_v2.tar.gz', 'context_memory.db'} +# Расширения файлов для включения в снимок (только исходный код) +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] @@ -1267,7 +1539,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()) @@ -1275,7 +1547,8 @@ 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)") ``` ## File: `./scripts/diagnostics/show_tree.py` @@ -3539,76 +3812,47 @@ def load_absences_from_db(date_str): ## File: `./modules/web_api/main.py` ```py -import json -import sqlite3 -import logging -import urllib.request +""" +=============================================================================== +FILE: modules/web_api/main.py +PROJECT: SCUD Orion AI (Unified Repository) +MODULE: web_api (Main Application Entry Point) +ROLE: Инициализация FastAPI приложения, подключение роутеров и статики. +=============================================================================== +""" + +# ANCHOR[APP_INIT_IMPORTS] import os -from datetime import datetime, timedelta -from typing import List, Dict, Any, Optional +import sys +import logging -import jwt -from passlib.context import CryptContext -from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Form -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) +if CURRENT_DIR not in sys.path: + sys.path.insert(0, CURRENT_DIR) + +from fastapi import FastAPI, HTTPException from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse -from pydantic import BaseModel +from fastapi.responses import FileResponse, JSONResponse +from fastapi.exceptions import RequestValidationError -from llm.agent import process_chat_message -from llm.db_tools import db_get_tasks, DB_PATH -from llm.file_parser import extract_text_from_file +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 +# ANCHOR[APP_CONFIG] logging.basicConfig( - level=logging.DEBUG, + level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[logging.StreamHandler()] ) -JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure" -ALGORITHM = "HS256" -OLLAMA_URL = "http://192.168.11.3:11434/api/chat" -MODEL_NAME = "qwen2.5:14b" +STATIC_DIR = os.path.join(CURRENT_DIR, "static") -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -security = HTTPBearer() +app = FastAPI(title="SCUD Orion AI Context API", version="2.5") -def get_db(): - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - return conn - -def create_access_token(user_id: int, username: str, is_admin: bool) -> str: - payload = { - "sub": str(user_id), - "username": username, - "is_admin": is_admin, - "exp": datetime.utcnow() + timedelta(days=30) - } - return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) - -def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: - try: - token = credentials.credentials - payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) - user_id = int(payload.get("sub")) - username = payload.get("username") - is_admin = bool(payload.get("is_admin", False)) - return {"id": user_id, "username": username, "is_admin": is_admin} - except Exception as e: - logging.warning(f"Auth error: {e}") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Недействительный или просроченный токен авторизации", - headers={"WWW-Authenticate": "Bearer"}, - ) - -app = FastAPI(title="SCUD Orion AI Context API") - -if os.path.exists("static"): - app.mount("/static", StaticFiles(directory="static"), name="static") -from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse +if os.path.exists(STATIC_DIR): + app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") @app.exception_handler(RequestValidationError) async def validation_exception_handler(request, exc): @@ -3618,207 +3862,48 @@ async def validation_exception_handler(request, exc): content={"detail": exc.errors(), "body": str(exc)} ) -class AuthRequest(BaseModel): - username: str - password: str - -class CreateUserRequest(BaseModel): - username: str - password: str - full_name: Optional[str] = None - is_admin: Optional[bool] = False - -class ChangePasswordRequest(BaseModel): - old_password: str - new_password: str - -# === API МАРШРУТЫ === +# ANCHOR[ROUTER_REGISTRATION] +app.include_router(auth_router) +app.include_router(admin_router) +app.include_router(tasks_router) +app.include_router(chat_router) +# ANCHOR[ROOT_STATIC_ROUTES] @app.get("/") def read_root(): - return FileResponse("static/index.html") + index_path = os.path.join(STATIC_DIR, "index.html") + if os.path.exists(index_path): + return FileResponse(index_path) + raise HTTPException(status_code=404, detail="Frontend index.html not found") @app.get("/favicon.ico") async def favicon(): - file_path = os.path.join("static", "favicon.ico") + file_path = os.path.join(STATIC_DIR, "favicon.ico") if os.path.exists(file_path): return FileResponse(file_path) raise HTTPException(status_code=404) - -@app.post("/api/v1/auth/login") -def login(req: AuthRequest): - username = req.username.strip().lower() - logging.info(f"===> Попытка входа для пользователя: {username}") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,)) - user = cursor.fetchone() - conn.close() - - if not user: - logging.warning(f"===> Ошибка: Пользователь {username} не найден") - raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") - - if not pwd_context.verify(req.password, user["password_hash"]): - logging.warning(f"===> Ошибка: Неверный пароль для {username}") - raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") - - is_admin = bool(user["is_admin"]) or (user["username"] == "puh") - token = create_access_token(user["id"], user["username"], is_admin) - logging.info(f"===> УСПЕХ: Авторизован пользователь {username}") - - return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin} - -@app.post("/api/v1/auth/change-password") -def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)): - if not req.new_password or len(req.new_password) < 4: - raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],)) - user = cursor.fetchone() - - if not user or not pwd_context.verify(req.old_password, user["password_hash"]): - conn.close() - raise HTTPException(status_code=400, detail="Неверный старый пароль") - - new_hash = pwd_context.hash(req.new_password) - cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"])) - conn.commit() - conn.close() - - logging.info(f"Пароль изменен для пользователя ID: {current_user['id']}") - return {"status": "success", "message": "Пароль успешно изменен"} - -@app.get("/api/v1/admin/users") -def list_users(current_user: Dict[str, Any] = Depends(get_current_user)): - if not current_user["is_admin"]: - raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC") - users = [dict(r) for r in cursor.fetchall()] - conn.close() - return users - -@app.post("/api/v1/admin/users") -def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)): - if not current_user["is_admin"]: - raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") - - username = req.username.strip().lower() - if not username or not req.password: - raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT id FROM users WHERE username = ?", (username,)) - if cursor.fetchone(): - conn.close() - raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует") - - pwd_hash = pwd_context.hash(req.password) - full_name = req.full_name.strip() if req.full_name else None - is_admin = 1 if req.is_admin else 0 - - cursor.execute( - "INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)", - (username, pwd_hash, full_name, is_admin) - ) - conn.commit() - conn.close() - - logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}") - return {"status": "success", "message": f"Пользователь {username} создан"} - -@app.delete("/api/v1/admin/users/{user_id}") -def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)): - if not current_user["is_admin"]: - raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") - - if user_id == current_user["id"]: - raise HTTPException(status_code=400, detail="Нельзя удалить самого себя") - - conn = get_db() - cursor = conn.cursor() - cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) - conn.commit() - conn.close() - - logging.info(f"Удален пользователь ID: {user_id}") - return {"status": "success", "message": "Пользователь удален"} - -@app.get("/api/v1/tasks") -def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): - return db_get_tasks(user_id=user["id"]) - -# ЧАТ С ПОДДЕРЖКОЙ ФАЙЛОВ И АВТОРИЗАЦИИ -@app.post("/api/v1/chat") -async def chat_endpoint( - session_id: str = Form("web_session_main"), - message: str = Form(""), - file: Optional[UploadFile] = File(default=None), - current_user: dict = Depends(get_current_user) -): - parsed_file = {"text": "", "image_b64": None} - if file and file.filename: - file_bytes = await file.read() - parsed_file = extract_text_from_file(file_bytes, file.filename) - - reply, history = process_chat_message( - user_id=current_user["id"], - user_message=message, - file_context=parsed_file["text"], - image_b64=parsed_file["image_b64"], - session_id=session_id - ) - return {"reply": reply, "history": history} - -@app.post("/api/v1/chat/guest") -async def guest_chat_endpoint( - session_id: str = Form("web_session_main"), - message: str = Form(""), - file: Optional[UploadFile] = File(default=None) -): - parsed_file = {"text": "", "image_b64": None} - if file and file.filename: - file_bytes = await file.read() - parsed_file = extract_text_from_file(file_bytes, file.filename) - - reply, history = process_chat_message( - user_id=0, - user_message=message, - file_context=parsed_file["text"], - image_b64=parsed_file["image_b64"], - session_id=session_id - ) - return {"reply": reply, "history": history} - -# === СТРОГО В КОНЦЕ: ФОЛЛБЭК СТАТИКИ === - @app.get("/{file_path:path}") def serve_static_fallback(file_path: str): clean_path = file_path.lstrip("/") + target = os.path.join(STATIC_DIR, clean_path) - target = os.path.join("static", clean_path) if os.path.isfile(target): + if clean_path.endswith(".js"): + return FileResponse(target, media_type="application/javascript") + elif clean_path.endswith(".css"): + return FileResponse(target, media_type="text/css") return FileResponse(target) + # Резервный рекурсивный поиск файла в static filename = os.path.basename(clean_path) - target_js = os.path.join("static/js", filename) - if filename.endswith(".js") and os.path.isfile(target_js): - return FileResponse(target_js, media_type="application/javascript") - - target_css = os.path.join("static/css", filename) - if filename.endswith(".css") and os.path.isfile(target_css): - return FileResponse(target_css, media_type="text/css") + for root, _, files in os.walk(STATIC_DIR): + if filename in files: + full_path = os.path.join(root, filename) + media = "application/javascript" if filename.endswith(".js") else "text/css" + return FileResponse(full_path, media_type=media) raise HTTPException(status_code=404, detail="File not found") - ``` ## File: `./modules/web_api/scripts/clear_history.py` @@ -3829,7 +3914,9 @@ def serve_static_fallback(file_path: str): import os import sqlite3 -DB_PATH = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db" +# Рассчитываем путь к общей БД data/scud_orion_ai.db в корне проекта +BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../")) +DB_PATH = os.path.join(BASE_DIR, "data", "scud_orion_ai.db") def clear_chat_history(): if not os.path.exists(DB_PATH): @@ -3861,120 +3948,6 @@ def clear_chat_history(): if __name__ == "__main__": clear_chat_history() - -``` - -## File: `./modules/web_api/scripts/diagnostics/inspect_db.py` -```py -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) -``` - -## File: `./modules/web_api/scripts/diagnostics/inspect_files.py` -```py -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) -``` - -## File: `./modules/web_api/scripts/diagnostics/make_code_snapshot.py` -```py -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)") ``` ## File: `./modules/web_api/scripts/diagnostics/show_tree.py` @@ -4002,6 +3975,2414 @@ if __name__ == "__main__": print_tree('.') ``` +## File: `./modules/web_api/static/index.html` +```html + + + + + + SCUD Orion AI — Context Manager + + + + + + + +
+
+
+
+ +
+
+

SCUD Orion AI

+

Авторизация в системе

+
+
+ +
+
+ + +
+ +
+ + +
+ + + + +
+ +
+
+
Или
+
+ + +
+
+ + + + + + + + +
+
+
+ +
+
+

SCUD Orion AI

+ Task & Context API +
+
+ +
+ + + + + + + + + + + +
+
+ + +
+
+ +
+

+ ИИ-Ассистент +

+

+ Привет! У каждого пользователя свое изолированное пространство задач. Вы можете задавать вопросы нейросети, прикреплять файлы или ставить персональные задачи. +

+
+
+ + + + + +
+
+ + + +
+ +
+ +
+
+
+ + + + + + + + + + + + + + +``` + +## File: `./modules/web_api/static/js/app.js` +```js +const AUTH_TOKEN_KEY = "scud_api_auth_token"; +const SESSION_ID = "web_session_main"; +const STORAGE_KEY = "scud_chat_input_history"; + +let API_TOKEN = localStorage.getItem(AUTH_TOKEN_KEY) || ""; +let CURRENT_USERNAME = localStorage.getItem("scud_username") || ""; +let IS_ADMIN = localStorage.getItem("scud_is_admin") === "true"; +let IS_GUEST = localStorage.getItem("scud_is_guest") === "true"; + +let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); +let historyIndex = -1; + +document.addEventListener("DOMContentLoaded", () => { + const userInputEl = document.getElementById("user-input"); + + if (userInputEl) { + userInputEl.addEventListener("input", function() { + this.style.height = "24px"; + const newHeight = Math.min(this.scrollHeight, 120); + this.style.height = newHeight + "px"; + }); + } + + if (IS_GUEST) { + hideAuthModal(); + updateUIState(); + } else if (API_TOKEN) { + hideAuthModal(); + updateUIState(); + if (typeof loadTasks === "function") { + loadTasks(); + } + } else { + showAuthModal(); + } +}); +``` + +## File: `./modules/web_api/static/js/auth.js` +```js +function showAuthModal() { + const el = document.getElementById("auth-modal"); + if (el) el.classList.remove("hidden"); +} + +function hideAuthModal() { + const el = document.getElementById("auth-modal"); + if (el) el.classList.add("hidden"); +} + +async function handleLogin(e) { + if (e && e.preventDefault) e.preventDefault(); + + const usernameInput = document.getElementById("auth-username-input"); + const passwordInput = document.getElementById("auth-password-input"); + const errorEl = document.getElementById("auth-error"); + + if (!usernameInput || !passwordInput) return; + + const username = usernameInput.value.trim(); + const password = passwordInput.value; + + if (!username || !password) return; + + if (errorEl) errorEl.classList.add("hidden"); + + try { + const res = await fetch("/api/v1/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }) + }); + + const data = await res.json(); + + if (res.status === 200) { + // Используем прямой строковый ключ, чтобы избежать ошибки ReferenceError + API_TOKEN = data.token; + CURRENT_USERNAME = data.username; + IS_ADMIN = data.is_admin; + IS_GUEST = false; + + localStorage.setItem("scud_api_auth_token", data.token); + localStorage.setItem("scud_username", data.username); + localStorage.setItem("scud_is_admin", data.is_admin ? "true" : "false"); + localStorage.removeItem("scud_is_guest"); + + hideAuthModal(); + updateUIState(); + + if (typeof loadTasks === 'function') { + loadTasks(); + } + } else { + if (errorEl) { + errorEl.innerText = data.detail || "Ошибка авторизации"; + errorEl.classList.remove("hidden"); + } + } + } catch (err) { + console.error("[Auth Error]", err); + if (errorEl) { + errorEl.innerText = "Ошибка соединения с сервером"; + errorEl.classList.remove("hidden"); + } + } +} + +function enableGuestMode() { + IS_GUEST = true; + API_TOKEN = ""; + CURRENT_USERNAME = "Гость"; + IS_ADMIN = false; + localStorage.setItem("scud_is_guest", "true"); + hideAuthModal(); + updateUIState(); +} + +function logout() { + localStorage.removeItem("scud_api_auth_token"); + localStorage.removeItem("scud_username"); + localStorage.removeItem("scud_is_admin"); + localStorage.removeItem("scud_is_guest"); + API_TOKEN = ""; + CURRENT_USERNAME = ""; + IS_ADMIN = false; + IS_GUEST = false; + showAuthModal(); +} + +function updateUIState() { + const tasksBtn = document.getElementById("tasks-drawer-btn"); + const adminBtn = document.getElementById("admin-users-btn"); + const changePwdBtn = document.getElementById("change-pwd-btn"); + const guestBadge = document.getElementById("guest-badge"); + const usernameBadge = document.getElementById("username-badge"); + + if (typeof IS_GUEST !== 'undefined' && IS_GUEST) { + if (tasksBtn) tasksBtn.classList.add("hidden"); + if (adminBtn) adminBtn.classList.add("hidden"); + if (changePwdBtn) changePwdBtn.classList.add("hidden"); + if (guestBadge) guestBadge.classList.remove("hidden"); + if (usernameBadge) usernameBadge.classList.add("hidden"); + } else { + if (tasksBtn) tasksBtn.classList.remove("hidden"); + if (changePwdBtn) changePwdBtn.classList.remove("hidden"); + if (guestBadge) guestBadge.classList.add("hidden"); + + if (usernameBadge) { + usernameBadge.innerText = (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME) ? CURRENT_USERNAME : "User"; + usernameBadge.classList.remove("hidden"); + } + + if (adminBtn) { + const isAdminUser = (typeof IS_ADMIN !== 'undefined' && IS_ADMIN) || (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME === "puh"); + if (isAdminUser) { + adminBtn.classList.remove("hidden"); + } else { + adminBtn.classList.add("hidden"); + } + } + } +} + +function openChangePasswordModal() { + const el = document.getElementById("change-pwd-modal"); + if (el) el.classList.remove("hidden"); +} + +function closeChangePasswordModal() { + const el = document.getElementById("change-pwd-modal"); + if (el) el.classList.add("hidden"); + + const err = document.getElementById("pwd-error"); + const succ = document.getElementById("pwd-success"); + if (err) err.classList.add("hidden"); + if (succ) succ.classList.add("hidden"); + + document.getElementById("old-pwd-input").value = ""; + document.getElementById("new-pwd-input").value = ""; + const confirmInput = document.getElementById("confirm-pwd-input"); + if (confirmInput) confirmInput.value = ""; +} + +async function handleChangePassword(e) { + if (e && e.preventDefault) e.preventDefault(); + const old_password = document.getElementById("old-pwd-input").value; + const new_password = document.getElementById("new-pwd-input").value; + const confirmInput = document.getElementById("confirm-pwd-input"); + const confirm_password = confirmInput ? confirmInput.value : new_password; + const errorEl = document.getElementById("pwd-error"); + const successEl = document.getElementById("pwd-success"); + + if (errorEl) errorEl.classList.add("hidden"); + if (successEl) successEl.classList.add("hidden"); + + if (new_password !== confirm_password) { + if (errorEl) { + errorEl.innerText = "Новые пароли не совпадают"; + errorEl.classList.remove("hidden"); + } + return; + } + + try { + const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + const res = await fetch("/api/v1/auth/change-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + token + }, + body: JSON.stringify({ old_password, new_password }) + }); + + const data = await res.json(); + + if (res.status === 200) { + if (successEl) { + successEl.innerText = "Пароль успешно изменен!"; + successEl.classList.remove("hidden"); + } + setTimeout(closeChangePasswordModal, 1500); + } else { + if (errorEl) { + errorEl.innerText = data.detail || "Ошибка при смене пароля"; + errorEl.classList.remove("hidden"); + } + } + } catch (err) { + if (errorEl) { + errorEl.innerText = "Ошибка соединения с сервером"; + errorEl.classList.remove("hidden"); + } + } +} + +function openAdminModal() { + const el = document.getElementById("admin-modal"); + if (el) el.classList.remove("hidden"); + loadUsersList(); +} + +function closeAdminModal() { + const el = document.getElementById("admin-modal"); + if (el) el.classList.add("hidden"); +} + +async function loadUsersList() { + const listEl = document.getElementById("admin-users-list"); + if (!listEl) return; + listEl.innerHTML = '
Загрузка пользователей...
'; + + try { + const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + const res = await fetch("/api/v1/admin/users", { + headers: { "Authorization": "Bearer " + token } + }); + const users = await res.json(); + + if (res.status === 200) { + listEl.innerHTML = users.map(u => { + const adminTag = u.is_admin ? 'ADMIN' : 'USER'; + const fullNameHtml = u.full_name ? `
${u.full_name}
` : ''; + const dateStr = u.created_at ? u.created_at.split(' ')[0] : '—'; + const deleteBtn = u.username !== CURRENT_USERNAME ? `` : 'Вы'; + + return ` +
+
+
+ ${u.username} + ${adminTag} +
+ ${fullNameHtml} +
Создан: ${dateStr}
+
+ ${deleteBtn} +
+ `; + }).join(""); + } else { + listEl.innerHTML = `
${users.detail}
`; + } + } catch (err) { + listEl.innerHTML = '
Ошибка загрузки пользователей
'; + } +} + +async function handleCreateUser(e) { + if (e && e.preventDefault) e.preventDefault(); + const username = document.getElementById("new-user-name").value.trim(); + const password = document.getElementById("new-user-pwd").value; + const fullNameInput = document.getElementById("new-user-fullname"); + const full_name = fullNameInput ? fullNameInput.value.trim() : ""; + const adminCheckbox = document.getElementById("new-user-is-admin"); + const is_admin = adminCheckbox ? adminCheckbox.checked : false; + const msgEl = document.getElementById("admin-msg"); + + if (msgEl) msgEl.classList.add("hidden"); + + try { + const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + const res = await fetch("/api/v1/admin/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + token + }, + body: JSON.stringify({ username, password, full_name, is_admin }) + }); + + const data = await res.json(); + + if (res.status === 200) { + document.getElementById("new-user-name").value = ""; + document.getElementById("new-user-pwd").value = ""; + if (fullNameInput) fullNameInput.value = ""; + if (adminCheckbox) adminCheckbox.checked = false; + loadUsersList(); + } else { + if (msgEl) { + msgEl.innerText = data.detail || "Ошибка"; + msgEl.classList.remove("hidden"); + } + } + } catch (err) { + if (msgEl) { + msgEl.innerText = "Ошибка связи с сервером"; + msgEl.classList.remove("hidden"); + } + } +} + +async function deleteUser(userId, username) { + if (!confirm("Удалить пользователя " + username + "?")) return; + + try { + const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + await fetch("/api/v1/admin/users/" + userId, { + method: "DELETE", + headers: { "Authorization": "Bearer " + token } + }); + loadUsersList(); + } catch (err) { + alert("Ошибка при удалении"); + } +} +``` + +## File: `./modules/web_api/static/js/chat.js` +```js +/** +=============================================================================== +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 `
Нет задач со статусом «${label}»
`; + } + + 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 = `В планах`; + if (isDone) { + statusPill = `Завершено`; + } else if (isProgress) { + statusPill = `В работе`; + } + + let prioPill = ""; + if (t.priority === "HIGH") { + prioPill = `HIGH`; + } + + return ` +
+
+ +
+
+ ${t.task_id} + ${prioPill} + ${statusPill} +
+ ${escapeHtml(t.title)} +
+
+ +
+ `; + }).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 ` +
+
+ + + + +
+ +
+ ${itemsHtml} +
+ +
+ + +
+
+ `; +} + +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 = `
+ ${escapeHtml(selectedFile.name)} +
` + userDisplayHtml; + } + + const userMsgHtml = ` +
+
+ ${userDisplayHtml} +
+
+ `; + 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 = ''; + + 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 = ''; + } else if (isDanger) { + btnClasses = "bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300"; + iconMarkup = ''; + } + + return ` + + `; + }).join(""); + + actionButtonsHtml = ` +
+ ${buttonsMarkup} +
+ `; + + // Если выведены кнопки подтверждения превью — блокируем ввод с клавиатуры + 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 = ` +
+

+ ${assistantTitle} +

+

${escapeHtml(replyText)}

+ ${interactiveWidgetHtml} + ${actionButtonsHtml} +
+ `; + chatWindow.insertAdjacentHTML("beforeend", botMsgHtml); + chatWindow.scrollTop = chatWindow.scrollHeight; + + clearAttachedFile(); + + if (!isGuest && typeof loadTasks === 'function') { + loadTasks(); + } + + } catch (err) { + console.error("[Chat Error]", err); + const errorHtml = ` +
+ Ошибка связи с сервером. +
+ `; + 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, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +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); + } +}); +``` + +## File: `./modules/web_api/static/js/tasks.js` +```js +/** +=============================================================================== +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-карточек в боковом меню. +=============================================================================== +*/ + +let currentFilter = 'ALL'; +let allTasks = []; + +// --- [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; + } + + try { + const res = await fetch("/api/v1/tasks", { + headers: { + "Authorization": "Bearer " + token, + "Content-Type": "application/json" + } + }); + + 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)) || []; + } 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 = `
Ошибка обработки списка задач
`; + } + } +} + +// --- [SECTION 4: DOM RENDERING] --- # ANCHOR[TASKS_RENDER_DOM] +function renderTasks() { + const container = document.getElementById("tasks-container"); + if (!container) return; + + if (!Array.isArray(allTasks)) { + allTasks = []; + } + + const filtered = allTasks.filter(t => currentFilter === "ALL" || t.status === currentFilter); + + if (filtered.length === 0) { + container.innerHTML = `
Нет задач с выбранным фильтром
`; + return; + } + + container.innerHTML = filtered.map(t => { + let statusBadge = "bg-slate-100 text-slate-600 border-slate-200"; + let statusLabel = "В планах"; + let cardBg = "bg-white"; + + 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 ? ` +
+ + Срок: ${t.due_date} +
` : ""; + + return ` +
+
+
+ ${t.task_id || t.id || 'TASK'} + ${t.priority || 'MEDIUM'} +
+ ${statusLabel} +
+

${t.title || t.description || ''}

+
+ + ${t.module || 'General'} +
+ ${dueDateHtml} +
+ `; + }).join(""); +} +``` + +## File: `./modules/web_api/static/js/chat/core.js` +```js +/** +=============================================================================== +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 вложений, блокировка ввода + при активных кнопках, вызов онлайн-редактора и отправка правок. +=============================================================================== +*/ + +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", "bg-slate-200/50"); + } else { + input.placeholder = "Команда, вопрос или перетащите файл сюда..."; + input.classList.remove("cursor-not-allowed", "opacity-60", "bg-slate-200/50"); + } + } + 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) { + if (text === "action:open_editor") { + openInlinePromptEditor(); + return; + } + + disableAllActionButtons(); + setInputLocked(false); + const input = document.getElementById("user-input"); + if (input) { + input.value = text; + sendMessage(); + } +} + +function openInlinePromptEditor() { + const editorContainer = document.getElementById("inline-prompt-editor-container"); + if (editorContainer) { + editorContainer.classList.remove("hidden"); + const textarea = document.getElementById("inline-prompt-textarea"); + if (textarea) textarea.focus(); + } +} + +function closeInlinePromptEditor() { + const editorContainer = document.getElementById("inline-prompt-editor-container"); + if (editorContainer) editorContainer.classList.add("hidden"); +} + +async function saveManualPromptDraft() { + const textarea = document.getElementById("inline-prompt-textarea"); + if (!textarea) return; + + const newText = textarea.value.trim(); + if (!newText) return; + + const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + + try { + const res = await fetch("/api/v1/chat/draft", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + token + }, + body: JSON.stringify({ + session_id: "web_session_main", + draft_text: newText + }) + }); + + if (res.ok) { + closeInlinePromptEditor(); + const previewTextEl = document.getElementById("prompt-preview-diff-view"); + if (previewTextEl) { + previewTextEl.innerText = newText; + } + alert("✓ Правки сохранены в черновике! Нажмите «Подтвердить» для применения в БД."); + } + } catch (err) { + alert("Ошибка сохранения черновика: " + err); + } +} + +function escapeHtml(text) { + if (!text) return ""; + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// --- [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 = `
+ ${escapeHtml(selectedFile.name)} +
` + userDisplayHtml; + } + + const userMsgHtml = ` +
+
+ ${userDisplayHtml} +
+
+ `; + 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 = ''; + + 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("заверш"); + const isEdit = labelLower.includes("редактир"); + + if (isPrimary) { + btnClasses = "bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm"; + iconMarkup = ''; + } else if (isDanger) { + btnClasses = "bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300"; + iconMarkup = ''; + } else if (isEdit) { + btnClasses = "bg-indigo-50 hover:bg-indigo-100 active:bg-indigo-200 text-indigo-700 border border-indigo-300"; + iconMarkup = ''; + } + + return ` + + `; + }).join(""); + + actionButtonsHtml = ` +
+ ${buttonsMarkup} +
+ `; + + // Блокируем ввод при наличии кнопок + setInputLocked(true); + } else { + setInputLocked(false); + } + + let previewEditorHtml = ""; + if (actionData && actionData.type === "PROMPT_PREVIEW" && actionData.raw_draft) { + previewEditorHtml = ` + + `; + } + + let interactiveWidgetHtml = ""; + if (actionData && actionData.type === "TASK_INTERACTIVE_CARD" && typeof renderInteractiveTaskCard === "function") { + interactiveWidgetHtml = renderInteractiveTaskCard(actionData.tasks); + } + + const botMsgHtml = ` +
+

+ ${assistantTitle} +

+
${replyText}
+ ${previewEditorHtml} + ${interactiveWidgetHtml} + ${actionButtonsHtml} +
+ `; + chatWindow.insertAdjacentHTML("beforeend", botMsgHtml); + chatWindow.scrollTop = chatWindow.scrollHeight; + + clearAttachedFile(); + + if (!isGuest && typeof loadTasks === 'function') { + loadTasks(); + } + + } catch (err) { + console.error("[Chat Error]", err); + const errorHtml = ` +
+ Ошибка связи с сервером. +
+ `; + chatWindow.insertAdjacentHTML("beforeend", errorHtml); + chatWindow.scrollTop = chatWindow.scrollHeight; + setInputLocked(false); + } finally { + const inputEl = document.getElementById("user-input"); + if (sendBtn && (!inputEl || !inputEl.disabled)) { + sendBtn.disabled = false; + sendBtn.classList.remove("opacity-50"); + } + } +} + +// --- [DRAG & DROP AND KEYBOARD LISTENERS] --- +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); + } +}); +``` + +## File: `./modules/web_api/static/js/chat/task_widget.js` +```js +/** +=============================================================================== +FILE: modules/web_api/static/js/chat/task_widget.js +ROLE: Генеративный UI интерактивных карточек задач внутри диалога чата + (фильтры, inline-чекбоксы статусов, быстрое добавление и удаление). +=============================================================================== +*/ + +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 `
Нет задач со статусом «${label}»
`; + } + + 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 = `В планах`; + if (isDone) { + statusPill = `Завершено`; + } else if (isProgress) { + statusPill = `В работе`; + } + + let prioPill = ""; + if (t.priority === "HIGH") { + prioPill = `HIGH`; + } + + return ` +
+
+ +
+
+ ${t.task_id} + ${prioPill} + ${statusPill} +
+ ${escapeHtml(t.title)} +
+
+ +
+ `; + }).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 ` +
+
+ + + + +
+ +
+ ${itemsHtml} +
+ +
+ + +
+
+ `; +} + +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); + } +} +``` + +## File: `./modules/web_api/static/css/styles.css` +```css +/* Плавное исчезновение текста сверху при скролле */ +.fade-scroll-top { + mask-image: linear-gradient(to bottom, transparent 0%, black 14px); + -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 14px); +} + +/* Скрытие стандартного скроллбара */ +.no-scrollbar::-webkit-scrollbar { + display: none; +} +.no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} + +/* Оптимизация под мобильный viewport (борьба со скачками клавиатуры на iOS/Android) */ +body { + min-height: 100vh; + min-height: -webkit-fill-available; +} + +``` + +## File: `./modules/web_api/routers/admin.py` +```py +""" +=============================================================================== +FILE: modules/web_api/routers/admin.py +ROLE: Администрирование пользователей и прав доступа. +=============================================================================== +""" + +# ANCHOR[ADMIN_ROUTER_IMPORTS] +import logging +from typing import Dict, Any, Optional +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from .auth import get_current_user, get_db, pwd_context + +router = APIRouter(prefix="/api/v1/admin", tags=["admin"]) + +# ANCHOR[ADMIN_SCHEMAS] +class CreateUserRequest(BaseModel): + username: str + password: str + full_name: Optional[str] = None + is_admin: Optional[bool] = False + +# ANCHOR[ADMIN_ENDPOINTS] +@router.get("/users") +def list_users(current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC") + users = [dict(r) for r in cursor.fetchall()] + conn.close() + return users + +@router.post("/users") +def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + username = req.username.strip().lower() + if not username or not req.password: + raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT id FROM users WHERE username = ?", (username,)) + if cursor.fetchone(): + conn.close() + raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует") + + pwd_hash = pwd_context.hash(req.password) + full_name = req.full_name.strip() if req.full_name else None + is_admin = 1 if req.is_admin else 0 + + cursor.execute( + "INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)", + (username, pwd_hash, full_name, is_admin) + ) + conn.commit() + conn.close() + + logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}") + return {"status": "success", "message": f"Пользователь {username} создан"} + +@router.delete("/users/{user_id}") +def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + if user_id == current_user["id"]: + raise HTTPException(status_code=400, detail="Нельзя удалить самого себя") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) + conn.commit() + conn.close() + + logging.info(f"Удален пользователь ID: {user_id}") + return {"status": "success", "message": "Пользователь удален"} +``` + +## File: `./modules/web_api/routers/auth.py` +```py +""" +=============================================================================== +FILE: modules/web_api/routers/auth.py +ROLE: Аутентификация, валидация JWT-токенов и управление паролями. +=============================================================================== +""" + +# ANCHOR[AUTH_ROUTER_IMPORTS] +import sqlite3 +import logging +from datetime import datetime, timedelta +from typing import Dict, Any, Optional + +import jwt +from passlib.context import CryptContext +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from pydantic import BaseModel + +from llm.db_tools import DB_PATH + +JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure" +ALGORITHM = "HS256" + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +security = HTTPBearer() + +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) + +# ANCHOR[AUTH_DB_HELPERS] +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + +def create_access_token(user_id: int, username: str, is_admin: bool) -> str: + payload = { + "sub": str(user_id), + "username": username, + "is_admin": is_admin, + "exp": datetime.utcnow() + timedelta(days=30) + } + return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) + +def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: + try: + token = credentials.credentials + payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) + user_id = int(payload.get("sub")) + username = payload.get("username") + is_admin = bool(payload.get("is_admin", False)) + return {"id": user_id, "username": username, "is_admin": is_admin} + except Exception as e: + logging.warning(f"Auth error: {e}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Недействительный или просроченный токен авторизации", + headers={"WWW-Authenticate": "Bearer"}, + ) + +# 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,)) + user = cursor.fetchone() + conn.close() + + if not user or not pwd_context.verify(req.password, user["password_hash"]): + logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}") + raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") + + is_admin = bool(user["is_admin"]) or (user["username"] == "puh") + token = create_access_token(user["id"], user["username"], is_admin) + logging.info(f"===> УСПЕХ: Авторизован пользователь {username}") + + return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin} + +@router.post("/change-password") +def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)): + if not req.new_password or len(req.new_password) < 4: + raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],)) + user = cursor.fetchone() + + if not user or not pwd_context.verify(req.old_password, user["password_hash"]): + conn.close() + raise HTTPException(status_code=400, detail="Неверный старый пароль") + + new_hash = pwd_context.hash(req.new_password) + cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"])) + conn.commit() + conn.close() + + logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}") + return {"status": "success", "message": "Пароль успешно изменен"} +``` + +## File: `./modules/web_api/routers/chat.py` +```py +""" +=============================================================================== +FILE: modules/web_api/routers/chat.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / routers +ROLE: Маршрутизация диалогов с LLM и эндпоинт сохранения онлайн-черновиков. +=============================================================================== +""" + +# ANCHOR[CHAT_ROUTER_IMPORTS] +from typing import Optional, Dict, Any +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 llm.file_parser import extract_text_from_file +from llm.db_tools import db_set_session_state, db_get_session_state + +router = APIRouter(prefix="/api/v1/chat", tags=["chat"]) + + +# ANCHOR[DRAFT_SCHEMA] +class UpdateDraftRequest(BaseModel): + session_id: str + draft_text: str + + +# ANCHOR[CHAT_ENDPOINTS] +@router.post("") +async def chat_endpoint( + session_id: str = Form("web_session_main"), + message: str = Form(""), + file: Optional[UploadFile] = File(default=None), + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Диалог авторизованного пользователя с агентом.""" + parsed_file = {"text": "", "image_b64": None} + if file and file.filename: + file_bytes = await file.read() + parsed_file = extract_text_from_file(file_bytes, file.filename) + + reply, history, action_type = process_chat_message( + user_id=current_user["id"], + user_message=message, + file_context=parsed_file["text"], + image_b64=parsed_file["image_b64"], + session_id=session_id + ) + return {"reply": reply, "history": history, "action_type": action_type} + + +@router.post("/guest") +async def guest_chat_endpoint( + session_id: str = Form("web_session_main"), + message: str = Form(""), + file: Optional[UploadFile] = File(default=None) +): + """Гостевой диалог (user_id=0).""" + parsed_file = {"text": "", "image_b64": None} + if file and file.filename: + file_bytes = await file.read() + parsed_file = extract_text_from_file(file_bytes, file.filename) + + reply, history, action_type = process_chat_message( + user_id=0, + user_message=message, + file_context=parsed_file["text"], + image_b64=parsed_file["image_b64"], + session_id=session_id + ) + return {"reply": reply, "history": history, "action_type": action_type} + + +@router.post("/draft") +def update_draft_endpoint( + req: UpdateDraftRequest, + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Обновляет черновик системного промпта напрямую из интерактивной онлайн-формы.""" + state = db_get_session_state(req.session_id) + 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()}) + return {"status": "success", "message": "Черновик успешно обновлен в сессии"} +``` + +## File: `./modules/web_api/routers/tasks.py` +```py +""" +=============================================================================== +FILE: modules/web_api/routers/tasks.py +ROLE: REST API управления задачами (GET / POST / PATCH / DELETE). +=============================================================================== +""" + +# ANCHOR[TASKS_ROUTER_IMPORTS] +from typing import Dict, Any, Optional +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from .auth import get_current_user +from llm.db_tools import ( + db_get_tasks, + db_add_task, + db_update_task_status, + db_delete_task +) + +router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"]) + +# ANCHOR[TASKS_SCHEMAS] +class CreateTaskRequest(BaseModel): + title: str + priority: Optional[str] = "MEDIUM" + module: Optional[str] = "general" + due_date: Optional[str] = None + +class UpdateTaskRequest(BaseModel): + status: Optional[str] = "COMPLETED" + due_date: Optional[str] = None + +# ANCHOR[TASKS_ENDPOINTS] +@router.get("") +def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): + """Получить список всех задач текущего авторизованного пользователя.""" + return db_get_tasks(user_id=user["id"]) + +@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 + ) + if "error" in res: + raise HTTPException(status_code=404, 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) + if "error" in res: + raise HTTPException(status_code=404, detail=res["error"]) + return res +``` + ## File: `./modules/web_api/llm/__init__.py` ```py @@ -4010,16 +6391,24 @@ if __name__ == "__main__": ## File: `./modules/web_api/llm/agent.py` ```py """ +=============================================================================== FILE: modules/web_api/llm/agent.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm (Core Agent Coordinator) +ROLE: Оркестратор диалога, диспетчер реляционных узлов промпта и инструментов. +=============================================================================== """ +import sys import json -import urllib.request import logging +import re from typing import List, Dict, Any, Tuple, Optional +from .db.connection import get_db_connection from .db_tools import ( db_get_active_system_prompt, - db_add_system_prompt, + db_apply_prompt_node_action, + db_get_tool_action, db_get_tasks, db_update_task_status, db_delete_task, @@ -4027,28 +6416,34 @@ from .db_tools import ( db_get_rules, db_set_session_state, db_get_session_state, + db_clear_session_state, db_get_snapshots, db_delete_snapshots, - db_clear_session_state, db_get_current_server_time, db_save_chat_message, db_get_chat_history, + db_purge_ephemeral_messages, db_get_stats, db_get_anomalies, - db_get_session_states, db_get_reference ) from .schemas import TOOLS_SCHEMA -from .core.calendar_utils import get_dynamic_calendar_context, parse_relative_date_ru +from .core.calendar_utils import get_dynamic_calendar_context from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed +from .core.ollama_client import call_ollama_chat +from .core.fast_path import handle_fast_path_intercept -logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger("SCUD_AGENT") +logger.setLevel(logging.INFO) +logger.propagate = False + +if not logger.handlers: + handler = logging.StreamHandler(sys.stdout) + formatter = logging.Formatter("%(asctime)s [%(levelname)s] [%(name)s] %(message)s") + handler.setFormatter(formatter) + logger.addHandler(handler) -OLLAMA_URL = "http://192.168.11.3:11434/api/chat" -TEXT_MODEL = "qwen2.5:14b" -VISION_MODEL = "qwen2.5vl:7b-q8_0" def process_chat_message( user_id: int, @@ -4057,151 +6452,244 @@ def process_chat_message( image_b64: Optional[str] = None, chat_history: List[Dict[str, Any]] = None, session_id: str = "web_session_main" -) -> Tuple[str, List[Dict[str, Any]]]: +) -> 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 = user_message - if file_context: - full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" + 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) + # 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 + + # 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) db_history = db_get_chat_history(session_id, limit=20) - db_save_chat_message(session_id, "user", full_user_content) + # 3. Сборка системного контекста dynamic_prompt_text = db_get_active_system_prompt() calendar_context = get_dynamic_calendar_context() - session_state = db_get_session_state(session_id) - preview_status_note = "\n\n[АКТИВНО ПРЕВЬЮ ПРОМПТА: Ожидается подтверждение или отмена изменений пользователем]." if session_state and session_state.get("state_type") == "PROMPT_PREVIEW" else "" user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" - system_prompt_content = ( - f"[ТЕКУЩИЙ АВТОРИЗОВАННЫЙ ПОЛЬЗОВАТЕЛЬ]\n" - f"Вы общаетесь с пользователем: {user_info}.\n" - f"При запросах 'покажи мои задачи', 'список задач' — СРАЗУ вызывай db_get_tasks.\n\n" - f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n" - f"{calendar_context}\n\n" - f"ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ:\n{dynamic_prompt_text}{preview_status_note}" - ) + active_state_context = "" + if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + active_state_context = ( + "\n[ВНИМАНИЕ: АКТИВНЫЙ РЕЖИМ ПРЕДПРОСМОТРА ПРОМПТА]\n" + "- Сейчас открыт предпросмотр изменения системного промпта.\n" + "- Если пользователь просит изменить, скорректировать формулировку или удалить пункт — вызови инструмент db_prompt_node_edit.\n" + "- Если пользователь переключился на другую тему (задачи, логи, аномалии, общие вопросы) — вежливо напомни ему, что открыто несохраненное превью, и спроси, желает ли он сначала применить или отменить правку промпта.\n" + ) - llm_options = { - "num_predict": 8192, - "num_ctx": 8192, - "temperature": 0.1, - "repeat_penalty": 1.1, - "presence_penalty": 0.5, - "top_p": 0.9 - } + 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.\n" + f" - Пример: 'добавь пункт 3.4 Текст' -> вызови db_prompt_node_edit(action='ADD', section_id=3, item_id=4, content='Текст')\n" + f" - Пример: 'удали пункт 2.9' -> вызови db_prompt_node_edit(action='DELETE', section_id=2, item_id=9)\n" + f" Категорически запрещено подтверждать добавление или удаление простым текстом без вызова db_prompt_node_edit!\n" + f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n" + f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n\n" + f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}" + ) user_msg_object = {"role": "user", "content": full_user_content} - if image_b64: - user_msg_object["images"] = [image_b64] - messages = [ - {"role": "system", "content": "Ты — модуль OCR. Перепиши весь текст с изображения буква в букву."}, - user_msg_object - ] - payload = {"model": VISION_MODEL, "messages": messages, "stream": False, "options": llm_options} - else: - clean_db_history = [dict(m) for m in db_history] - for m in clean_db_history: - m.pop("images", None) - messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object] - payload = {"model": TEXT_MODEL, "messages": messages, "tools": TOOLS_SCHEMA, "stream": False, "options": llm_options} - + # 4. Вызов модели try: - req = urllib.request.Request(OLLAMA_URL, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}) - with urllib.request.urlopen(req) as response: - res_data = json.loads(response.read().decode("utf-8")) - msg = res_data.get("message", {}) - tool_calls = msg.get("tool_calls", []) - raw_text_content = msg.get("content", "") + if image_b64: + user_msg_object["images"] = [image_b64] + messages = [ + {"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву."}, + user_msg_object + ] + msg = call_ollama_chat(messages, is_vision=True) + else: + clean_db_history = [dict(m) for m in db_history] + for m in clean_db_history: + m.pop("images", None) + messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object] + msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False) - user_msg_lower = user_message.lower() - if not tool_calls: - if any(w in user_msg_lower for w in ["покажи системный промпт", "покажи промпт", "весь промпт"]): - tool_calls = [{"function": {"name": "db_get_system_prompt", "arguments": {}}}] - elif any(w in user_msg_lower for w in ["покажи снапшоты", "список снапшотов", "выведи снапшоты"]): - target_date = parse_relative_date_ru(user_message) - tool_calls = [{"function": {"name": "db_get_snapshots", "arguments": {"date_str": target_date}}}] - else: - tool_calls = inject_tools_if_needed(user_message, raw_text_content, tool_calls) + raw_text_reply = msg.get("content", "") + tool_calls = msg.get("tool_calls", []) + tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls) - if tool_calls: - messages.append(msg) - for tool in tool_calls: - fn_name = tool["function"]["name"] - fn_args = tool["function"].get("arguments", {}) - tool_result_content = "" + if tool_calls: + logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})") + messages.append(msg) + tool_names_called = [] - if 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) - 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_tasks": - tool_result_content = json.dumps(db_get_tasks(user_id), ensure_ascii=False) - 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) - 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_delete_snapshots": - tool_result_content = json.dumps(db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str")), ensure_ascii=False) - elif fn_name == "db_get_reference": - tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False) - elif fn_name == "db_preview_prompt_merge": - proposed_text = fn_args.get("prompt_text") or fn_args.get("proposed_prompt") or "" - if isinstance(fn_args, str): - proposed_text = fn_args - db_set_session_state(session_id, "PROMPT_PREVIEW", proposed_text) - preview_reply = f"Ваше изменение успешно предпросмотрено:\n\n{proposed_text}\n\nДля применения подтвердите («подтверждаю») или отмените («отмена»)." - db_save_chat_message(session_id, "assistant", preview_reply) - return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id) - elif fn_name == "db_confirm_prompt_preview": - if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": - res = db_add_system_prompt("main_agent", session_state.get("pending_data", "")) - db_clear_session_state(session_id) - tool_result_content = json.dumps(res, ensure_ascii=False) + 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 = "" + + if fn_name == "db_get_tasks": + raw_tasks = db_get_tasks(user_id) + reply_text = "Вот интерактивный список ваших текущих задач:" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "TASK_INTERACTIVE_CARD", + "tasks": raw_tasks + } + + elif fn_name == "db_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() + + 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} + + if action == "DELETE": + nodes_dict.pop((sec_id, itm_id), None) + else: + nodes_dict[(sec_id, itm_id)] = content + + 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 else: - tool_result_content = json.dumps({"status": "error", "message": "Нет активного превью."}, ensure_ascii=False) - elif fn_name == "db_cancel_prompt_preview": - db_clear_session_state(session_id) - tool_result_content = json.dumps({"status": "success", "message": "Превью отменено."}, ensure_ascii=False) - elif fn_name == "db_get_rules": - tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) - elif fn_name == "db_add_task": - res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date")) - tool_result_content = json.dumps(res, ensure_ascii=False) - elif fn_name == "db_update_task_status": - res = db_update_task_status(user_id=user_id, task_id=str(fn_args.get("task_id")), status=fn_args.get("status", "COMPLETED"), due_date=fn_args.get("due_date")) - tool_result_content = json.dumps(res, ensure_ascii=False) - elif fn_name == "db_delete_task": - res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper()) - tool_result_content = json.dumps(res, ensure_ascii=False) + line_str = f" {s_id}.{i_id}. {txt}" + if s_id == sec_id and i_id == itm_id: + line_str = f'{s_id}.{i_id}. {txt}' + virtual_lines.append(line_str) - messages.append({"role": "tool", "content": tool_result_content}) + merged_prompt = "\n".join([re.sub(r'<[^>]+>', '', l) for l in virtual_lines]) + diff_html = "\n".join(virtual_lines) - second_payload = {"model": TEXT_MODEL, "messages": messages, "stream": False, "options": llm_options} - sec_req = urllib.request.Request(OLLAMA_URL, data=json.dumps(second_payload).encode("utf-8"), headers={"Content-Type": "application/json"}) - with urllib.request.urlopen(sec_req) as sec_response: - sec_res_data = json.loads(sec_response.read().decode("utf-8")) - raw_content = sec_res_data.get("message", {}).get("content", "").strip().replace("**", "") - final_content = clean_raw_tool_tags(clean_output(raw_content)) - db_save_chat_message(session_id, "assistant", final_content) - return final_content, db_get_chat_history(session_id) + db_set_session_state(session_id, "PROMPT_PREVIEW", { + "draft_text": merged_prompt, + "action": action, + "section_id": sec_id, + "item_id": itm_id, + "content": content + }) - raw_str = msg.get("content", "").strip().replace("**", "") - content_str = clean_raw_tool_tags(clean_output(raw_str)) - final_reply = content_str or "Запрос обработан." - db_save_chat_message(session_id, "assistant", final_reply) - return final_reply, db_get_chat_history(session_id) + 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, + "buttons": [ + {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, + {"label": "Отменить", "value": "отмена", "style": "danger"}, + {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"} + ] + } + + 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) + + 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) + + 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) + + 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}) + + 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)) + + 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 + + db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral) + return final_content, db_get_chat_history(session_id), None + + # Обычный текстовый ответ модели (включая вежливый Guard при смене темы) + raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "") + final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан." + + # Если находимся в превью и модель ответила текстом (напомнила о незавершенном изменении), прикрепляем кнопки + action_payload = None + if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + action_payload = { + "type": "PROMPT_PREVIEW", + "buttons": [ + {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, + {"label": "Отменить", "value": "отмена", "style": "danger"}, + {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"} + ] + } + + db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=(1 if session_state else 0)) + return final_reply, db_get_chat_history(session_id), action_payload except Exception as ex: logger.exception(f"Непредвиденная ошибка: {ex}") error_reply = f"Внутренняя ошибка сервера: {ex}" - return error_reply, db_get_chat_history(session_id) + return error_reply, db_get_chat_history(session_id), None ``` ## File: `./modules/web_api/llm/db_tools.py` @@ -4211,17 +6699,18 @@ FILE: modules/web_api/llm/db_tools.py """ 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 +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 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, - db_get_session_states, db_get_stats, db_get_anomalies, db_get_reference @@ -4361,8 +6850,12 @@ TOOLS_SCHEMA = [ "type": "function", "function": { "name": "db_get_system_prompt", - "description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СИСТЕМНЫЙ ПРОМПТ ИЗ БАЗЫ ДАННЫХ. Ты ОБЯЗАН СРАЗУ вызывать эту функцию при любых запросах 'покажи системный промпт', 'покажи промпт', 'текущие инструкции'. Запрещено выводить промпт из памяти без вызова этой функции!", - "parameters": {"type": "object", "properties": {}} + "description": "ВЫЗЫВАЙ ВСЕГДА при наличии в сообщении фраз: 'покажи системный промпт', 'покажи промпт', 'выведи промпт', 'системный промпт'. Запрещено отвечать текстом без вызова этого инструмента.", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } } }, { @@ -4412,18 +6905,18 @@ TOOLS_SCHEMA = [ } }, { - "type": "function", - "function": { - "name": "db_delete_snapshots", - "description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.", - "parameters": { - "type": "object", - "properties": { - "snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"}, - "day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"} - } + "type": "function", + "function": { + "name": "db_delete_snapshots", + "description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.", + "parameters": { + "type": "object", + "properties": { + "snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"}, + "day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"} } } + } }, { "type": "function", @@ -4503,51 +6996,37 @@ TOOLS_SCHEMA = [ { "type": "function", "function": { - "name": "db_add_system_prompt", - "description": "Прямое сохранение системного промпта в БД без предварительного просмотра.", + "name": "db_prompt_node_edit", + "description": ( + "Сформировать предпросмотр изменения системного промпта через реляционные узлы. " + "Вызывай при любых запросах на добавление, изменение или удаление пунктов. " + "Раздел и пункт передавай числами (например, для пункта '2.9' -> section_id=2, item_id=9)." + ), "parameters": { "type": "object", "properties": { - "name": {"type": "string", "description": "Имя промпта, по умолчанию main_agent"}, - "prompt_text": {"type": "string", "description": "Полный текст системного промпта"} - }, - "required": ["prompt_text"] - } - } - }, - { - "type": "function", - "function": { - "name": "db_preview_prompt_merge", - "description": "Создать предварительное изменённое превью системного промпта перед сохранением.", - "parameters": { - "type": "object", - "properties": { - "prompt_text": { + "action": { "type": "string", - "description": "Новый полный или частично измененный текст системного промпта." + "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": ["prompt_text"] - } - } - }, - { - "type": "function", - "function": { - "name": "db_confirm_prompt_preview", - "description": "Подтвердить и сохранить текущее подготовленное превью в БД. Вызывай этот инструмент, когда пользователь говорит 'подтверждаю', 'да', 'вноси', 'применяй', 'сохраняй' или одобряет превью в любой форме.", - "parameters": {"type": "object", "properties": {}} - } - }, - { - "type": "function", - "function": { - "name": "db_cancel_prompt_preview", - "description": "Отменить текущее превью системного промпта и сбросить изменения. Вызывай, когда пользователь явно отказывается от изменений.", - "parameters": {"type": "object", "properties": {}} + "required": ["action", "section_id", "item_id"] } } + } ] ``` @@ -4581,76 +7060,207 @@ def get_db_connection() -> sqlite3.Connection: ```py """ FILE: modules/web_api/llm/db/db_chat.py +ROLE: Управление историей сообщений и очисткой эфемерного контекста. """ from typing import List, Dict, Any from .connection import get_db_connection -def db_save_chat_message(session_id: str, role: str, content: str): - if not content: - return +def db_save_chat_message(session_id: str, role: str, content: str, is_ephemeral: int = 0) -> None: + """Сохраняет сообщение в БД (is_ephemeral=1 для временных служебных шагов, 0 для постоянных).""" conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" - INSERT INTO chat_messages (session_id, role, content, created_at) - VALUES (?, ?, ?, CURRENT_TIMESTAMP) - """, (session_id, role, content)) + INSERT INTO chat_messages (session_id, role, content, is_ephemeral) + VALUES (?, ?, ?, ?) + """, (session_id, role, content, is_ephemeral)) conn.commit() conn.close() + def db_get_chat_history(session_id: str, limit: int = 20) -> List[Dict[str, Any]]: + """Получает последние сообщения истории диалога в хронологическом порядке.""" conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" - SELECT role, content FROM chat_messages + SELECT role, content + FROM chat_messages WHERE session_id = ? - ORDER BY id DESC LIMIT ? + ORDER BY id DESC + LIMIT ? """, (session_id, limit)) rows = cursor.fetchall() conn.close() return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)] + + +def db_purge_ephemeral_messages(session_id: str) -> int: + """ + Физически удаляет все временные служебные сообщения выбранной сессии + после завершения сценария работы с инструментом. + """ + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM chat_messages WHERE session_id = ? AND is_ephemeral = 1", (session_id,)) + deleted = cursor.rowcount + conn.commit() + conn.close() + return deleted + + +def db_clear_chat_history(session_id: str) -> None: + """Полная очистка всех сообщений сессии.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM chat_messages WHERE session_id = ?", (session_id,)) + conn.commit() + conn.close() ``` ## File: `./modules/web_api/llm/db/db_prompts.py` ```py """ +=============================================================================== FILE: modules/web_api/llm/db/db_prompts.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm / db +ROLE: Реляционное управление системным промптом (таблица system_prompt_nodes), + базой знаний, реестром действий и сессионными стейтами. +=============================================================================== """ +import json import logging from typing import List, Dict, Any, Optional from .connection import get_db_connection logger = logging.getLogger("DB_PROMPTS") -def db_get_active_system_prompt() -> str: + +def init_prompt_nodes_table(): + """Создает реляционную таблицу узлов промпта и заполняет базовыми данными.""" + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS system_prompt_nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prompt_name TEXT DEFAULT 'main_agent', + section_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + content TEXT NOT NULL, + is_active INTEGER DEFAULT 1, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(prompt_name, section_id, item_id) + ); + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);") + + cursor.execute("SELECT COUNT(*) FROM system_prompt_nodes WHERE prompt_name = 'main_agent'") + if cursor.fetchone()[0] == 0: + seed_nodes = [ + # Раздел 1 + (1, 0, "РОЛЬ И ЗАДАЧИ АССИСТЕНТА"), + (1, 1, "Управление бэклогом задач проекта (через db_get_tasks, db_add_task, db_update_task_status, db_delete_task)."), + (1, 2, "Консультация по правилам и арбитражу кадровых данных/СКУД из базы знаний (через db_get_rules)."), + (1, 3, "Предоставление справки о возможностях и примерах команд (СТРОГО через db_get_reference)."), + (1, 4, "Просмотр аномалий СКУД ⟷ 1С (СТРОГО через db_get_anomalies)."), + (1, 5, "Поддержка диалога с операторами и администраторами системы."), + (1, 7, "Работа с логами, снапшотами и срезами СКУД (через db_get_snapshots)."), + + # Раздел 2 + (2, 0, "ПРАВИЛА ВЫЗОВА ИНСТРУМЕНТОВ И ДАТ"), + (2, 1, "ОБЯЗАТЕЛЬНЫЙ ПРЕВЬЮ-МЕРДЖ: Категорически ЗАПРЕЩЕНО изменять промпт напрямую! При ЛЮБОМ запросе пользователя на изменение системного промпта Ты ОБЯЗАН вызвать инструмент db_prompt_node_edit."), + (2, 2, "БЕЗУСЛОВНОЕ ПОДТВЕРЖДЕНИЕ ПРЕВЬЮ: Инструмент db_confirm_prompt_preview вызывается СТРОГО после того, как пользователь напишет 'подтверждаю', 'да', 'сохраняй'."), + (2, 3, "ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧ: При запросе на удаление задач КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО сразу вызывать db_delete_task! Ты ОБЯЗАН сначала спросить пользователя подтверждение."), + (2, 4, "ИСПОЛЬЗОВАНИЕ КАЛЕНДАРЯ: При любых вопросах про даты БЕРИ ТОЧНУЮ ДАТУ ИЗ [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА] В НАЧАЛЕ КОНТЕКСТА."), + (2, 5, "СТРОГИЙ ВЫЗОВ АНОМАЛИЙ: При запросах аномалий или нарушений Ты ОБЯЗАН вызвать инструмент db_get_anomalies."), + (2, 6, "СТРОГИЙ ВЫЗОВ СПРАВОЧНИКА: При запросах о возможностях, примерах запросов или командах Ты ОБЯЗАН вызывать db_get_reference."), + (2, 7, "Запрещено писать названия функций или код вызова текстом на экран."), + (2, 8, "СТРОГИЙ ВЫЗОВ СНАПШОТОВ: При ЛЮБЫХ запросах про снапшоты, срезы или логи СКУД за дату/день недели Ты ОБЯЗАН вызывать инструмент db_get_snapshots."), + + # Раздел 3 + (3, 0, "ПРАВИЛА СТИЛЯ"), + (3, 1, "Никогда не начинай ответ со склеек или слов-паразитов."), + (3, 2, "Отвечай в чистом текстовом формате (plain text) без спецсимволов или ###, если прямо не попросили."), + (3, 3, "Сохраняй инженерный, лаконичный и профессиональный стиль.") + ] + cursor.executemany(""" + INSERT OR IGNORE INTO system_prompt_nodes (prompt_name, section_id, item_id, content) + VALUES ('main_agent', ?, ?, ?) + """, seed_nodes) + conn.commit() + + +def db_get_active_system_prompt(prompt_name: str = "main_agent") -> str: + """Собирает структурированный текст промпта из реляционной таблицы узлов.""" + init_prompt_nodes_table() + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT section_id, item_id, content + FROM system_prompt_nodes + WHERE prompt_name = ? AND is_active = 1 + ORDER BY section_id ASC, item_id ASC + """, (prompt_name,)) + rows = cursor.fetchall() + + if not rows: + return "Ты — ИИ-ассистент SCUD Orion AI." + + lines = [] + current_section = None + + for sec_id, itm_id, content in rows: + if itm_id == 0: + if current_section is not None: + lines.append("") + lines.append(f"{sec_id}. {content}") + current_section = sec_id + else: + lines.append(f" {sec_id}.{itm_id}. {content}") + + return "\n".join(lines) + + +def db_apply_prompt_node_action(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent"): + """Прямое добавление, изменение или удаление узла в БД.""" + init_prompt_nodes_table() + with get_db_connection() as conn: + cursor = conn.cursor() + action_clean = action.upper() + if action_clean in ["ADD", "UPDATE"]: + cursor.execute(""" + INSERT INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active, updated_at) + VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP) + ON CONFLICT(prompt_name, section_id, item_id) DO UPDATE SET + content = excluded.content, + is_active = 1, + updated_at = CURRENT_TIMESTAMP + """, (prompt_name, section_id, item_id, content)) + elif action_clean == "DELETE": + cursor.execute(""" + DELETE FROM system_prompt_nodes + WHERE prompt_name = ? AND section_id = ? AND item_id = ? + """, (prompt_name, section_id, item_id)) + conn.commit() + + +def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]: conn = get_db_connection() cursor = conn.cursor() - cursor.execute("SELECT prompt_text FROM system_prompts WHERE is_active = 1 ORDER BY id DESC LIMIT 1") + cursor.execute(""" + SELECT tool_name, category, bypass_llm, success_template, + follow_up_question, action_type, buttons_json, is_ephemeral + FROM tool_action_registry + WHERE tool_name = ? AND is_active = 1 + """, (tool_name,)) row = cursor.fetchone() conn.close() - return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI." + + if row: + res = dict(row) + res["buttons"] = json.loads(res["buttons_json"]) if res.get("buttons_json") else [] + return res + return None -def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: - try: - with get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute("BEGIN IMMEDIATE;") - cursor.execute("SELECT id FROM system_prompts WHERE name = ?", (name,)) - existing = cursor.fetchone() - if existing: - cursor.execute( - "UPDATE system_prompts SET prompt_text = ?, is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE name = ?", - (prompt_text, name) - ) - else: - cursor.execute( - "INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)", - (name, prompt_text) - ) - conn.commit() - return {"status": "success", "message": "Системный промпт успешно обновлен"} - except Exception as e: - logger.error(f"Ошибка при сохранении промпта в БД: {e}") - return {"status": "error", "error": str(e)} def db_get_rules() -> List[Dict[str, Any]]: conn = get_db_connection() @@ -4660,9 +7270,12 @@ def db_get_rules() -> List[Dict[str, Any]]: conn.close() return [dict(r) for r in rows] -def db_set_session_state(session_id: str, state_type: str, data: str): + +def db_set_session_state(session_id: str, state_type: str, data: Any) -> None: + """Сохраняет состояние сессии в SQLite.""" conn = get_db_connection() cursor = conn.cursor() + payload_str = json.dumps(data, ensure_ascii=False) if isinstance(data, (dict, list)) else (str(data) if data is not None else "") cursor.execute(""" INSERT INTO session_states (session_id, state_type, pending_data, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) @@ -4670,37 +7283,40 @@ def db_set_session_state(session_id: str, state_type: str, data: str): state_type = excluded.state_type, pending_data = excluded.pending_data, updated_at = CURRENT_TIMESTAMP - """, (session_id, state_type, data)) + """, (session_id, state_type, payload_str)) conn.commit() conn.close() + def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]: conn = get_db_connection() cursor = conn.cursor() - cursor.execute("SELECT state_type, pending_data FROM session_states WHERE session_id = ?", (session_id,)) + cursor.execute("SELECT session_id, state_type, pending_data, updated_at FROM session_states WHERE session_id = ?", (session_id,)) row = cursor.fetchone() conn.close() - return dict(row) if row else None + if not row: + return None + res = dict(row) + raw_data = res.get("pending_data") or "" + try: + res["data_json"] = json.loads(raw_data) if raw_data.strip().startswith(("{", "[")) else None + except Exception: + res["data_json"] = None + return res -def db_clear_session_state(session_id: str): + +def db_clear_session_state(session_id: str) -> None: conn = get_db_connection() cursor = conn.cursor() cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) conn.commit() conn.close() -def db_get_session_states() -> Dict[str, Any]: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT session_id, state_type, updated_at FROM session_states") - rows = cursor.fetchall() - conn.close() - return {"status": "success", "active_sessions": [dict(r) for r in rows]} def db_get_stats() -> Dict[str, Any]: conn = get_db_connection() cursor = conn.cursor() - tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks'] + tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompt_nodes', 'session_states', 'tasks'] stats = {} for t in tables: try: @@ -4711,6 +7327,7 @@ def db_get_stats() -> Dict[str, Any]: conn.close() return {"status": "success", "tables_stats": stats} + def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]: conn = get_db_connection() cursor = conn.cursor() @@ -4726,6 +7343,7 @@ def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[s conn.close() return {"status": "success", "count": len(rows), "anomalies": [dict(r) for r in rows]} + def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]: conn = get_db_connection() cursor = conn.cursor() @@ -4739,6 +7357,53 @@ def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]: rows = cursor.fetchall() conn.close() return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]} + +def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: + """Мост обратной совместимости: парсит текст промпта и сохраняет по узлам в БД.""" + try: + init_prompt_nodes_table() + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM system_prompt_nodes WHERE prompt_name = ?", (name,)) + + current_sec = 1 + current_itm = 0 + + for line in prompt_text.splitlines(): + stripped = line.strip() + if not stripped: + 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 + else: + current_itm += 1 + content = stripped + + cursor.execute(""" + INSERT OR REPLACE INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active) + VALUES (?, ?, ?, ?, 1) + """, (name, current_sec, current_itm, content)) + + conn.commit() + return {"status": "success", "message": "Системный промпт успешно сохранен по узлам"} + except Exception as e: + logger.error(f"Ошибка при разборе промпта в узлы: {e}") + return {"status": "error", "error": str(e)} ``` ## File: `./modules/web_api/llm/db/db_snapshots.py` @@ -4925,16 +7590,241 @@ def get_dynamic_calendar_context() -> str: return "\n".join(lines) ``` +## File: `./modules/web_api/llm/core/fast_path.py` +```py +""" +=============================================================================== +FILE: modules/web_api/llm/core/fast_path.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm / core +ROLE: Детерминированный перехват подтверждения, отмены и мгновенной очистки + эфемерного контекста без повторного вывода кнопок-вилок. +=============================================================================== +""" + +# ANCHOR[FAST_PATH_IMPORTS] +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 +) + +logger = logging.getLogger("FAST_PATH") + + +# ANCHOR[FAST_PATH_ROUTER] +def handle_fast_path_intercept( + session_id: str, + user_message: str, + full_user_content: str, + session_state: Optional[Dict[str, Any]] +) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]: + """ + 1. Подтвердить -> запись в БД + db_purge_ephemeral_messages + инфо-текст. + 2. Отменить -> сброс стейта + db_purge_ephemeral_messages + инфо-текст. + """ + if not session_state or session_state.get("state_type") != "PROMPT_PREVIEW": + return None + + user_msg_clean = user_message.lower().strip(" .!?:;") + + 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", "") + + # 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}") + + reply_text = "Системный промпт успешно сохранен и применен в базе данных." + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) + return reply_text, db_get_chat_history(session_id), None + + # 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}") + + reply_text = "Изменения системного промпта отменены. Контекст диалога чист." + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) + return reply_text, db_get_chat_history(session_id), None + + return None +``` + +## File: `./modules/web_api/llm/core/ollama_client.py` +```py +""" +=============================================================================== +FILE: modules/web_api/llm/core/ollama_client.py +ROLE: Транспортный клиент HTTP взаимодействия с локальным API Ollama. +=============================================================================== +""" + +# ANCHOR[OLLAMA_CLIENT_IMPORTS] +import json +import urllib.request +import urllib.error +import logging +from typing import Dict, Any, Optional + +logger = logging.getLogger("OLLAMA_CLIENT") + +OLLAMA_URL = "http://192.168.11.3:11434/api/chat" +TEXT_MODEL = "qwen2.5:14b" +VISION_MODEL = "qwen2.5vl:7b-q8_0" + +LLM_OPTIONS = { + "num_predict": 8192, + "num_ctx": 8192, + "temperature": 0.1, + "repeat_penalty": 1.1, + "presence_penalty": 0.5, + "top_p": 0.9 +} + + +# ANCHOR[OLLAMA_REQUEST_DISPATCHER] +def call_ollama_chat( + messages: list, + tools: Optional[list] = None, + is_vision: bool = False, + timeout: int = 120 +) -> Dict[str, Any]: + """ + Отправляет подготовленный массив сообщений в API Ollama. + Возвращает разобранный словарь сообщения ответа или генерирует исключение. + """ + model_name = VISION_MODEL if is_vision else TEXT_MODEL + payload = { + "model": model_name, + "messages": messages, + "stream": False, + "options": LLM_OPTIONS + } + + if tools and not is_vision: + payload["tools"] = tools + + req = urllib.request.Request( + OLLAMA_URL, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"} + ) + + with urllib.request.urlopen(req, timeout=timeout) as response: + res_data = json.loads(response.read().decode("utf-8")) + return res_data.get("message", {}) +``` + +## File: `./modules/web_api/llm/core/prompt_merger.py` +```py +""" +=============================================================================== +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'{line}') + else: + diff_lines.append(line) + + return "\n".join(diff_lines) +``` + ## File: `./modules/web_api/llm/core/tool_injector.py` ```py +""" +=============================================================================== +FILE: modules/web_api/llm/core/tool_injector.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm / core +ROLE: Санитарная очистка сырых артефактов и тегов из ответов LLM. +=============================================================================== +""" import re -import json import logging from typing import List, Dict, Any logger = logging.getLogger("TOOL_INJECTOR") + def clean_raw_tool_tags(text: str) -> str: + """Удаляет сырые теги вызова инструментов, если модель случайно вывела их в текст.""" if not text: return "" text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*()?', '', text) @@ -4943,28 +7833,30 @@ def clean_raw_tool_tags(text: str) -> str: text = re.sub(r'', '', text) return text.strip() + def clean_output(text: str) -> str: + """Удаляет слова-паразиты и склейки в начале ответа.""" if not text: return text - artifacts = ["почемучто", "почто", "почему что"] - lower_text = text.lower() + + artifacts = [ + "почемучка", "почемучка,", "почемучка!", "почемучка?", + "почемучто", "почто", "почему что", "почему-то", + "здравствуйте!", "привет!" + ] + + cleaned = text.strip() for art in artifacts: - if lower_text.startswith(art): - text = text[len(art):].lstrip(",.!?:; -") - return text.strip() + if cleaned.lower().startswith(art): + cleaned = cleaned[len(art):].lstrip(",.!?:; -") + + 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 '' 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}") + """ + Проходной фильтр: все решения о вызове инструментов принимает исключительно LLM. + """ return tool_calls ``` diff --git a/scripts/db_cli.py b/scripts/db_cli.py index 781afd2..e24cec3 100644 --- a/scripts/db_cli.py +++ b/scripts/db_cli.py @@ -51,11 +51,14 @@ 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'] + tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks'] for t in tables: - cursor.execute(f"SELECT COUNT(*) FROM {t}") - cnt = cursor.fetchone()[0] - print(f" • Таблица [{t:<18}]: {cnt:>6} записей") + try: + cursor.execute(f"SELECT COUNT(*) FROM {t}") + cnt = cursor.fetchone()[0] + print(f" • Таблица [{t:<20}]: {cnt:>6} записей") + except Exception: + pass print("=" * 60 + "\n") @@ -148,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 @@ -236,28 +239,35 @@ def dump_all_to_excel(out_filename="db_dump_full.xlsx"): out_path = os.path.join(OUTPUT_DIR, out_filename) print(f"\n[🔄] Создание полного дампа БД в файл: {out_path} ...") with get_connection() as conn, pd.ExcelWriter(out_path, engine='openpyxl') as writer: - for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base']: - df = pd.read_sql_query(f"SELECT * FROM {table}", conn) - df.to_excel(writer, sheet_name=table[:31], index=False) + for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks']: + try: + df = pd.read_sql_query(f"SELECT * FROM {table}", conn) + df.to_excel(writer, sheet_name=table[:31], index=False) + except Exception: + pass print(f"[✓] Дамп успешно сохранен: {out_path}\n") def print_system_prompts(): """Выводит все системные промпты из базы данных.""" print("\n" + "=" * 80) - print("📝 СИСТЕМНЫЕ ПРОМПТЫ (system_prompts):") + print("📝 СИСТЕМНЫЕ ПРОМПТЫ (system_prompt_nodes):") print("=" * 80) with get_connection() as conn: cursor = conn.cursor() - cursor.execute("SELECT id, name, is_active, updated_at, prompt_text FROM system_prompts ORDER BY id DESC") - rows = cursor.fetchall() + try: + cursor.execute("SELECT section_id, item_id, content, is_active, updated_at FROM system_prompt_nodes ORDER BY section_id ASC, item_id ASC") + rows = cursor.fetchall() + except Exception: + rows = [] + if not rows: - print("Таблица system_prompts пуста.") + print("Таблица system_prompt_nodes пуста.") else: for r in rows: - print(f"ID: {r[0]} | Name: {r[1]} | Active: {r[2]} | Updated: {r[3]}") + print(f"Раздел {r[0]}.{r[1]} | Active: {r[3]} | Updated: {r[4]}") + print(f" {r[2]}") print("-" * 80) - print(f"{r[4]}\n") print("=" * 80 + "\n") @@ -280,6 +290,89 @@ def print_session_states(): print("=" * 80 + "\n") +def print_chat_messages(session_id=None, limit=50): + """Выводит таблицу истории сообщений чата (контекст).""" + print("\n" + "=" * 105) + print(f"💬 ИСТОРИЯ СООБЩЕНИЙ ЧАТА (chat_messages) {f'для сессии: {session_id}' if session_id else 'все сессии'}:") + print("=" * 105) + + with get_connection() as conn: + cursor = conn.cursor() + if session_id: + cursor.execute(""" + SELECT id, session_id, role, content, is_ephemeral, created_at + FROM chat_messages + WHERE session_id = ? + ORDER BY id ASC + LIMIT ? + """, (session_id, limit)) + else: + cursor.execute(""" + SELECT id, session_id, role, content, is_ephemeral, created_at + FROM chat_messages + ORDER BY id DESC + LIMIT ? + """, (limit,)) + + rows = cursor.fetchall() + if not rows: + print("Таблица chat_messages пуста.") + else: + if not session_id: + rows = list(reversed(rows)) + + for r in rows: + msg_id, sess, role, content, ephemeral, created = r + eph_marker = " [ЭФЕМЕРНОЕ]" if ephemeral else "" + print(f"[{msg_id}] {created} | Сессия: {sess} | Роль: {role.upper()}{eph_marker}") + print("-" * 105) + content_preview = content if content else "" + print(f"{content_preview}") + print("=" * 105) + print("\n") + + +def purge_chat_context(session_id=None, purge_all=False): + """ + Очистка контекста сообщений: + - По умолчанию: удаляет эфемерные сообщения, осиротевшие превью и сбрасывает стейты сессий. + - purge_all=True (--all): полностью очищает всю таблицу chat_messages и сбрасывает сессии. + """ + with get_connection() as conn: + cursor = conn.cursor() + + if purge_all: + if session_id: + cursor.execute("DELETE FROM chat_messages WHERE session_id = ?", (session_id,)) + cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) + else: + cursor.execute("DELETE FROM chat_messages") + cursor.execute("DELETE FROM session_states") + deleted_msgs = cursor.rowcount + conn.commit() + print(f"\n[✓] Полная очистка истории выполнена! Удалено сообщений: {deleted_msgs}\n") + return + + query = """ + DELETE FROM chat_messages + WHERE is_ephemeral = 1 + OR content LIKE '%Предпросмотр изменений%' + OR content LIKE '%Удален пункт:%' + OR content LIKE '%добавлен пункт:%' + """ + if session_id: + cursor.execute(query + " AND session_id = ?", (session_id,)) + cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) + else: + cursor.execute(query) + cursor.execute("DELETE FROM session_states") + + deleted_msgs = cursor.rowcount + conn.commit() + + print(f"\n[✓] Умная зачистка контекста выполнена! Удалено сообщений: {deleted_msgs}\n") + + HELP_TEXT = """ CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db) @@ -290,9 +383,11 @@ CLI-утилита инспекции и управления SQLite базой absences [ДД.ММ.ГГГГ] -- Посмотреть список официально отсутствующих из 1С:ЗУП anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С rules -- Посмотреть правила Базы Знаний ИИ из SQLite - prompts -- Посмотреть системные промпты (system_prompts) + prompts -- Посмотреть узлы системного промпта (system_prompt_nodes) 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 или всех за выбранный день @@ -300,13 +395,15 @@ CLI-утилита инспекции и управления SQLite базой python scripts/db_cli.py stats python scripts/db_cli.py snapshots 06.08.2026 python scripts/db_cli.py scud 06.08.2026 --export-xlsx срез_четверг - python scripts/db_cli.py scud --snapshot Y20260805-007 python scripts/db_cli.py absences 07.08.2026 python scripts/db_cli.py prompts 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 snapshot del --day 04.08.2026 python scripts/db_cli.py dump my_dump.xlsx """ @@ -321,12 +418,14 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, add_help=False ) - parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot', 'tools'], help="Основная команда") - parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del')") - parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота или имя файла)") + 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', '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() @@ -354,6 +453,14 @@ def main(): print_session_states() elif args.command == 'tools': print_tool_actions() + elif args.command == 'context': + if args.action in ['purge', 'clear']: + is_all = args.all or (args.param == '--all') + sess_id = None if (args.param == '--all' or not args.param) else args.param + purge_chat_context(session_id=sess_id, purge_all=is_all) + else: + sess_id = args.action if args.action else None + print_chat_messages(session_id=sess_id, limit=args.limit) elif args.command == 'dump': filename = args.param if args.param else "db_dump_full.xlsx" dump_all_to_excel(filename) @@ -370,7 +477,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__": diff --git a/scripts/diagnostics/inspect_ephemeral.py b/scripts/diagnostics/inspect_ephemeral.py new file mode 100644 index 0000000..5e15fe9 --- /dev/null +++ b/scripts/diagnostics/inspect_ephemeral.py @@ -0,0 +1,58 @@ +import os +import sqlite3 + +DB_PATH = os.path.join(os.path.dirname(__file__), "../../data/scud_orion_ai.db") +if not os.path.exists(DB_PATH): + DB_PATH = "data/scud_orion_ai.db" + +conn = sqlite3.connect(DB_PATH) +cursor = conn.cursor() + +print("=" * 90) +print(f"🔍 ИНСПЕКЦИЯ СООБЩЕНИЙ В БАЗЕ: {DB_PATH}") +print("=" * 90) + +# 1. Проверяем наличие колонки is_ephemeral +cursor.execute("PRAGMA table_info(chat_messages);") +cols = [c[1] for c in cursor.fetchall()] + +if "is_ephemeral" not in cols: + print("❌ Колонка is_ephemeral отсутствует в таблице chat_messages!") + conn.close() + exit(1) + +# 2. Считаем общее количество и распределение +cursor.execute("SELECT COUNT(*) FROM chat_messages") +total_count = cursor.fetchone()[0] + +cursor.execute("SELECT COUNT(*) FROM chat_messages WHERE is_ephemeral = 1") +ephemeral_count = cursor.fetchone()[0] + +cursor.execute("SELECT COUNT(*) FROM chat_messages WHERE is_ephemeral = 0") +permanent_count = cursor.fetchone()[0] + +print(f"Всего записей в истории: {total_count}") +print(f" • Постоянные (is_ephemeral = 0): {permanent_count}") +print(f" • Фантомные / Эфемерные (is_ephemeral = 1): {ephemeral_count}") +print("-" * 90) + +# 3. Выводим все фантомные записи +if ephemeral_count == 0: + print("В базе нет фантомных записей (is_ephemeral = 1 пуст).") +else: + print(f"📋 СПИСОК ВСЕХ ФАНТОМНЫХ СООБЩЕНИЙ ({ephemeral_count} шт.):") + cursor.execute(""" + SELECT id, session_id, role, created_at, content + FROM chat_messages + WHERE is_ephemeral = 1 + ORDER BY id ASC + """) + for row in cursor.fetchall(): + msg_id, session_id, role, created_at, content = row + print(f"\n[ID: {msg_id}] | Сессия: {session_id} | Роль: {role} | Время: {created_at}") + print("Содержимое:") + print(content.strip()) + print("-" * 70) + +conn.close() +print("=" * 90) \ No newline at end of file diff --git a/scripts/diagnostics/make_code_snapshot.py b/scripts/diagnostics/make_code_snapshot.py index 8942f22..7d88141 100644 --- a/scripts/diagnostics/make_code_snapshot.py +++ b/scripts/diagnostics/make_code_snapshot.py @@ -2,15 +2,29 @@ import os OUTPUT_SNAPSHOT = "project_code_snapshot.md" -# Расширения файлов для включения в снимок -ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini'} -EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} -EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_orion_ai_v2.tar.gz', 'context_memory.db'} +# Расширения файлов для включения в снимок (только исходный код) +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)") \ No newline at end of file +size_kb = os.path.getsize(OUTPUT_SNAPSHOT) / 1024 +print(f"✓ Слепок успешно создан: {OUTPUT_SNAPSHOT} ({size_kb:.1f} KB)") \ No newline at end of file