33 changed files with 5672 additions and 2014 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+54
View File
@@ -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].
@@ -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.
+61
View File
@@ -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 контуре.
@@ -0,0 +1,223 @@
# **SCUD Orion AI — Полная энциклопедическая хроника, архитектурный паспорт, разбор всех инцидентов и технический контекст проекта**
**Версия документа:** 4.0 (Исчерпывающий монолитный контекстный паспорт для новой сессии)
**Стек технологий и инфраструктура:** Python 3.11+, FastAPI (Uvicorn), SQLite (WAL mode, PRAGMA synchronous=NORMAL, внешние ключи ON), Ollama REST API (Qwen 2.5:14b — основной текстовый агент и классификатор, Qwen 2.5-VL:7b-q8\_0 / Qwen 3-VL:8b — OCR и зрение), MS SQL Server 2019/2022 (СКУД «Орион Pro» ЗАО НВП «Болид», 1С:ЗУП 3.1 через pyodbc / ODBC Driver 18), сетевая Samba-шара (/mnt/scud\_share), Git/Gitea, HTML5 / Vanilla JS / Tailwind CSS.
## ---
**1\. Исходный бизнес-контекст, роли систем и фундаментальные задачи**
Проект решает комплексную проблему контроллинга трудовой дисциплины, автоматического сопоставления кадровых фактов и защиты от рассинхронизации мастер-данных на предприятии со штатом \~270–276 сотрудников.
### **Источники первичных данных:**
> * **СКУД «Орион Pro» (Болид):** База данных MS SQL Server (SERVER: 172.16.31.221\\SQL, DB: Orion-14.01.21-1). Основные таблицы:
* pLogData — журнал всех сырых физических транзакций турникетов, калиток и внутренних дверей. Ключевые поля: TimeVal (время сервера), DeviceTime (время контроллера), HozOrgan / Hrk (ID сотрудника), Event (код события: 26, 28, 32 — вход; 27, 29, 33 — выход; 54, 55, 64, 65 — внутренние считыватели), Mode (направление: 1 — IN, 2 — OUT), ReaderIndex, DoorIndex.
* pList — реестр физических лиц и пропусков (ФИО, табельный номер, статус архива).
* PDivision, PPost — справочники подразделений и должностей СКУД.
> * **1С:ЗУП 3.1 (Кадры):** База данных MS SQL Server (SERVER: ACCOUNT-01, DB: ZUP30).
* dbo.\_Reference299 — эталонный справочник сотрудников.
* dbo.\_InfoRg16921 — периодический регистр сведений состояний сотрудников (отпуска основные, больничные, командировки, декретные отпуска, отпуска без сохранения оплаты).
* Файловые выгрузки с сетевой шары /mnt/scud\_share: файлы Штат\_ДД\_ММ\_ГГГГ.xlsx (шапка 7–8 строк) и Отсутствия\_ДД\_ММ\_ГГГГ.xlsx (шапка 3–4 строки).
> * **Реестры статических исключений:** static\_reason\_workers.csv (реестр удаленщиков и постоянных разъездных сотрудников), exceptions.json (декларативный фильтр обслуживающего персонала и спец-отделов).
## ---
**2\. Подробнейшая пошаговая хронология разработки, эволюция идей и разбор ошибок**
### **Фаза 1: Первые эксперименты с LLM, выявление галлюцинаций и гибридная парадигма**
> * **Попытка передачи сырых списков в чат:** Первоначальная попытка скормить модели Qwen 2.5:14b список из сотен ФИО с инструкцией *«запомни список 1С»* показала, что у LLM нет постоянной памяти между сессиями. Модель теряла контекст, путала фамилии и выдавала ответы с троеточиями ('Васильев Л.И.', ...) или придумывала несуществующие строки (*«ЛЕНМОРНИИПРОЕКТ ОЭС Варламова Мария»*).
> * **Ошибки openpyxl vs pandas:** Первая генерация кода моделью использовала библиотеку openpyxl с построчным перебором ячеек, жестко зашитыми опечатками (поиск слова 'фиио') и падением по FileNotFoundError.
> * **Переход к детерминированному пайплайну:** Принято ключевое архитектурное решение — **LLM не считает математику и не фильтрует 300 строк текста**. Всю механическую работу (ETL, слияние, фильтрацию) выполняет быстрый детерминированный Python (pandas, SQL), а локальная LLM выступает исключительно в роли «умного ревизора» на этапе сопоставления сомнительного несовпавшего остатка (Diff).
### **Фаза 2: Нормализация ФИО, кодировки и коллизия совместителей (Кейс Балаевой О.Г.)**
> * **Символьные ловушки:**
* Латинские буквы-двойники (омоглифы a, c, e, o, p, x, y, случайно набранные в английской раскладке).
* Служебные суффиксы в скобках, добавляемые 1С ((осн.), (совм.), (внешн.)).
* Буквы ё/е, двойные пробелы, неразрывные пробелы \\xa0 и случайный ввод цифры 0 вместо буквы О.
> * **Функция normalize\_fio():** Разработан строгий конвейер очистки:
> `def normalize_fio(fio):`
> `if not fio or not isinstance(fio, str): return ""`
> `fio_clean = re.sub(r'\(.*?\)', '', fio) # Удаление скобок`
> `fio_clean = fio_clean.replace('\xa0', ' ')`
> `trans = str.maketrans('aceopxyABCEHKMOPTX', 'асеорхуАВСЕНКМОРТХ')`
> `fio_clean = fio_clean.translate(trans)`
> `fio_clean = fio_clean.replace('ё', 'е').replace('Ё', 'Е')`
> `return " ".join(fio_clean.strip().split()).title()`
> * **Коллизия 270 vs 271:** В СКУД числилось 270 уникальных человек, а в 1С — 271 строка. Ручной разбор показал, что **Балаева Ольга Геннадьевна** занимает две ставки в 1С (*Руководитель управления персоналом* и *Руководитель режимно-секретного отдела*) при одном физическом пропуске в СКУД.
> * **Решение:** Внедрена обязательная группировка штата 1С по fio\_clean с объединением должностей и подразделений через " / ".
### **Фаза 3: Защита Entity Resolution от однофамильцев (Инцидент с Семеновыми)**
> * **Сбой модели:** При передаче несовпавших ФИО в Qwen 2.5 модель ошибочно сопоставила *«Семенову Юлию Борисовну»* (которая была на работе) с *«Семеновой Диной Дмитриевной»* (находившейся в отпуске), посчитав это опечаткой в имени из\-за одинаковой фамилии. В результате Юлия Борисовна получила статус нарушителя («в отпуске, но пришла»).
> * **Двухконтурная защита:**
1. В системный промпт ИИ зашито категорическое правило: однофамильцы с разными именами/отчествами — строго разные люди, объединение запрещено.
2. В Python встроен дублирующий барьер: если фамилии совпадают, но инициалы/первые буквы имен различаются, связь ИИ принудительно аннулируется с логированием ⚠️ \[Блокировка фейка\].
### **Фаза 4: Декларативные исключения и синонимы отделов**
> * **Переход к exceptions.json:** Исключение клининга, дворников, гардеробщиков и хозяйственного персонала отдела «ЭТО» вынесено из кода в конфигурационный файл exceptions.json (поддержка списков departments, positions, fio, position\_keywords).
> * **База синонимов отделов (department\_synonyms):** Для сопоставления сокращений СКУД с длинными названиями 1С (например, *«ОВК» ⟷ «Отдел внутреннего контроля»*) создана таблица SQLite, автоматически обучаемая при подтверждениях оператора.
### **Фаза 5: Аппаратный сбой СКУД 04.08.2026, разгадка «исчезающих неизвестных» и Первая активность**
> * **Анатомия сбоя:** Утром 04.08.2026 зависли регистраторы входа на главном КПП. У 119 сотрудников не записалось событие входа (time\_in \= 'Нет входа'). Из них 57 человек были официально закрыты документами 1С (отпуска, командировки). Оставшиеся \~74 человека реально пришли на работу, но утренней отметки не имели. Однако вечером сводка выдала всего 4–7 «неизвестных».
> * **Вскрытие механизма ошибки:** Было обнаружено, что скрипт агрегации видел дневные перемещения сотрудников (выходы на обед, считыватели туалетов, внутренние двери) и автоматически выставлял Статус \= 'Присутствовал' и is\_present \= 1\. Из-за этого люди попадали в категорию «Итого на работе», а массовый аппаратный сбой скрывался.
> * **Внедрение «Первой активности»:**
1. В базу данных и выгрузки Excel добавлена колонка **first\_activity («Первая активность»)** — минимальное время любого события дня в здании (MIN(TimeVal) AS FirstRawEvent без учета направления двери).
2. Если time\_in \== 'Нет входа', но first\_activity \!= '—', записи присваивается anomaly\_flag \= 'ANOMALY\_NO\_IN\_HAS\_ACTIVITY', а is\_present принудительно сбрасывается в 0\.
3. Внедрен модуль analyze\_scud\_mass\_failure\_ai(): при превышении порога в 5% смены (\>10 человек) ИИ поднимает тревогу о массовом сбое контроллеров КПП.
> * **Кейс Равина В.Э.:** Сотрудник находился в официальном отпуске, утреннего входа не имел, но пришел после обеда (первая активность 14:11:06, выход 17:18:38). Система корректно зафиксировала истинную кадровую аномалию (физическое присутствие при официальном отпуске).
### **Фаза 6: Архитектурная миграция под Gitea и система срезов (снапшотов)**
> * **Безопасный рефакторинг:** Монолит разделен на слои (core/, services/, scripts/, modules/web\_api/) через git mv с сохранением истории коммитов в ветке refactor/modular-structure.
> * **Формат ID снапшота:** Введена схема \[Y\]YYYYMMDD-NNN (например, Y20260804-001 для вчерашнего зафиксированного вечернего среза на 22:00 и 20260805-002 для дневного).
> * **Оптимизация экспорта:** Если в SQLite уже зафиксирован вчерашний срез с префиксом Y, повторный тяжелый SQL-запрос к СКУД Орион не выполняется (функция has\_yesterday\_final\_snapshot).
> * **Инспекция и CLI:** Написан инструмент scripts/db\_cli.py с командами stats, snapshots, scud, absences, anomalies, rules, prompts, tools, sessions, dump, snapshot del.
### **Фаза 7: Web API, Generative UI, управление памятью LLM и Topic Drift Guard**
> * **FastAPI & Безопасность:** Развернут Web API с JWT-авторизацией (bcrypt), изолированными задачами пользователей (tasks с привязкой к user\_id), гостевым режимом и ролевой моделью.
> * **Проблема деградации контекста LLM:** Вывод объемных списков задач, текстов системных промптов и таблиц снапшотов засорял контекстное окно чата, приводя к галлюцинациям и «залипанию» модели на старых правилах.
> * **Решение проблемы памяти (Topic Drift & Ephemeral Context):**
* В таблицу chat\_messages добавлен флаг is\_ephemeral \= 1 для временных сообщений.
* Введены двухфазные сессии (PROMPT\_PREVIEW → PROMPT\_FOLLOWUP) и детерминированный счетчик отвлечений idle\_turns.
* **Context Guard:** На 3-м шаге отвлечения оператора на сторонние темы система отвечает на вопрос и выводит интерактивное напоминание с кнопками \[Завершить настройку\] / \[Показать промпт\]. При idle\_turns \> 3 происходит автоочистка сессии вызовом db\_purge\_ephemeral\_messages().
* **Fast-Path:** Прямой перехват команд подтверждения («да», «подтверждаю», «сохранить») и выхода («нет, спасибо», «закончить настройку») без обращения к LLM (ответ за 0.05 сек).
* **Декларативный реестр действий (tool\_action\_registry):** Хранение шаблонов ответов, типов кнопок и признаков эфемерности в SQLite.
> * **Generative UI (Интерактивные карточки задач):** Инструмент db\_get\_tasks возвращает полезную нагрузку TASK\_INTERACTIVE\_CARD. Фронтенд рендерит живой виджет с табами (Все / В работе / В планах / Завершенные), чекбоксами и inline-созданием задач через REST API без вызова LLM.
## ---
**3\. Полная схема базы данных SQLite (data/scud\_orion\_ai.db)**
| Таблица | Назначение и бизнес-логика | Полная структура колонок |
| :---- | :---- | :---- |
| **scud\_logs** | Хранение логов проходов СКУД, расчетного времени и снапшотов. | id (PK), log\_date, fio, fio\_clean, department, position, time\_in, first\_activity, time\_out, time\_in\_building, is\_present, anomaly\_flag, snapshot\_time, snapshot\_id, created\_at |
| **zup\_staff** | Штатное расписание 1С:ЗУП на даты срезов. | id (PK), snapshot\_date, fio, fio\_clean, department, position, created\_at |
| **zup\_absences** | Официальные кадровые отклонения 1С (отпуска, больничные, командировки, декреты). | id (PK), absence\_date, fio, fio\_clean, absence\_type, created\_at |
| **anomalies\_history** | Журнал выявленных кадровых и аппаратных аномалий. | id (PK), anomaly\_date, fio, anomaly\_type, details, human\_status, created\_at |
| **ai\_knowledge\_base** | Глобальные правила арбитража и приоритеты компании для ИИ. | id (PK), rule\_text, added\_by, created\_at |
| **system\_prompts** | Версионные системные промпты агента (активный промпт по is\_active=1). | id (PK), name, prompt\_text, is\_active, updated\_at |
| **tool\_action\_registry** | Декларативный реестр шаблонов, кнопок и настроек эфемерности инструментов. | id (PK), tool\_name, category, bypass\_llm, success\_template, follow\_up\_question, action\_type, buttons\_json, is\_active, is\_ephemeral, updated\_at |
| **session\_states** | Активные состояния сессий, черновики и счетчик idle\_turns. | session\_id (PK), state\_type, pending\_data, updated\_at |
| **chat\_messages** | История диалогов с поддержкой разделения на постоянные и эфемерные сообщения. | id (PK), session\_id, role, content, is\_ephemeral, created\_at |
| **tasks** | Реестр задач пользователей с привязкой к user\_id. | id (PK), task\_id, module, title, priority, status, due\_date, user\_id, created\_at |
| **users** | Учетные записи операторов (хэши паролей bcrypt, права администратора). | id (PK), username, password\_hash, is\_admin, full\_name, created\_at |
| **department\_synonyms** | База знаний соответствия аббревиатур СКУД полным названиям отделов 1С. | id (PK), short\_name, full\_name, created\_at |
| **system\_reference** | Справочник команд и примеров промптов для операторов. | id (PK), category, title, example\_prompt, description |
## ---
**4\. Актуальная архитектура каталогов и назначение файлов**
`scud_ai/`
`├── config.py # Конфигурация: пути, расчет дат, URL Ollama, параметры MS SQL`
`├── exceptions.json # Декларативные фильтры исключений (ОВК, уборщики, подрядчики)`
`├── main_etl.py # Оркестратор ETL: экспорт СКУД/1С -> SQLite -> Excel/MD отчеты`
`├── project_code_snapshot.md # Полный исходный код всех файлов репозитория`
`├── core/`
`│ └── database.py # Драйвер SQLite (WAL), функции сохранения/загрузки срезов`
`├── services/`
`│ ├── scud_export.py # Прямой SQL-экспорт из MS SQL «Орион Pro»`
`│ ├── zup_extractor.py # Прямой SQL-экспорт из MS SQL 1С:ЗУП 3.1`
`│ ├── data_loader.py # Загрузка данных, нормализация ФИО, расчет первой активности`
`│ ├── data_validator.py # Проверка свежести и наличия файлов за Вчера и Сегодня`
`│ ├── excel_exporter.py # Генерация сводки и детального отчета в Excel (openpyxl)`
`│ ├── text_reporter.py # Построение итогового Markdown-отчета через Qwen 2.5`
`│ ├── ai_verifier.py # Модули ИИ-аудита, Fuzzy Matching и детекции сбоев СКУД`
`│ ├── feedback_loop.py # Интерактивный консольный модуль Human-in-the-Loop`
`│ ├── knowledge_base.py # Интерфейс работы с базой знаний правил`
`│ └── share_copier.py # Резервное скачивание файлов 1С с шары /mnt/scud_share`
`├── scripts/`
`│ ├── db_cli.py # CLI-утилита управления БД, снапшотами, дампами и сессиями`
`│ ├── fix_snapshots.py # Скрипт переиндексации и нормализации снапшотов`
`│ ├── init_tool_registry.py # Инициализация декларативного реестра инструментов`
`│ └── diagnostics/`
`│ ├── inspect_db.py # Диагностика структуры таблиц и количества записей`
`│ ├── inspect_ephemeral.py # Анализ распределения постоянных и эфемерных сообщений`
`│ ├── inspect_files.py # Аудит файлов и размеров на диске`
`│ └── show_tree.py # Вывод дерева каталогов`
`├── modules/web_api/`
`│ ├── main.py # Точка входа FastAPI приложения, роутеры и раздача статики`
`│ ├── routers/`
`│ │ ├── admin.py # Управление пользователями`
`│ │ ├── auth.py # JWT аутентификация и смена паролей`
`│ │ ├── chat.py # Эндпоинты диалогов (авторизованный и гостевой)`
`│ │ └── tasks.py # REST API управления задачами`
`│ ├── llm/`
`│ │ ├── agent.py # Координатор диалога, Tool Calls и Topic Drift Guard`
`│ │ ├── schemas.py # Описание JSON-схем инструментов для Ollama`
`│ │ ├── file_parser.py # Мультимодальный парсер файлов (PDF, PNG, XLSX, TXT)`
`│ │ ├── db_tools.py # Фасадные вызовы функций БД для агента`
`│ │ ├── core/`
`│ │ │ ├── calendar_utils.py # Формирование системного контекста дат и дней недели`
`│ │ │ ├── fast_path.py # Быстрый перехват команд подтверждения/отмены (0.05 с)`
`│ │ │ ├── ollama_client.py # HTTP-клиент взаимодействия с Ollama API`
`│ │ │ ├── prompt_merger.py # Парсинг и слияние правок системного промпта`
`│ │ │ └── tool_injector.py # Очистка сырых тегов и артефактов Tool Calls`
`│ │ └── db/`
`│ │ ├── connection.py # Подключение к scud_orion_ai.db`
`│ │ ├── db_chat.py # Сохранение истории и очистка эфемерных сообщений`
`│ │ ├── db_prompts.py # Системные промпты, стейты и реестр действий`
`│ │ ├── db_snapshots.py # Выборка и удаление снапшотов`
`│ │ └── db_tasks.py # CRUD операции с задачами пользователей`
`│ └── static/`
`│ ├── index.html # SPA разметка (модалки, чат, боковая панель задач)`
`│ ├── css/styles.css # Стили и мобильные оптимизации`
`│ └── js/`
`│ ├── app.js # Инициализация приложения`
`│ ├── auth.js # Логика входа, смены паролей и администрирования`
`│ ├── chat.js # Обработка сообщений, Drag&Drop, виджет задач и кнопки`
`│ └── tasks.js # Управление боковой панелью задач (Drawer)`
`└── docs/`
`└── development_roadmap.md # Стратегическая дорожная карта и архитектурный бэклог`
## ---
**5\. Справочник команд командной строки (CLI Reference)**
### **Главный конвейер контроллинга (main\_etl.py)**
> * python main\_etl.py — стандартный дневной запуск: экспорт свежих данных СКУД и 1С, сохранение снапшота в SQLite, генерация Excel и Markdown отчетов.
> * python main\_etl.py \--skip-export — построение отчетов по последнему имеющемуся снапшоту из SQLite без повторного запроса к MS SQL.
> * python main\_etl.py \--snapshot 20260805-001 — точный расчет отчетов по указанному составному ID снапшота.
> * python main\_etl.py \-d — запуск в режиме расширенной отладки (DEBUG).
### **CLI-утилита управления SQLite базой данных (scripts/db\_cli.py)**
> * python scripts/db\_cli.py stats — общая статистика количества записей по всем таблицам БД.
> * python scripts/db\_cli.py snapshots \[ДД.ММ.ГГГГ\] — просмотр реестра снапшотов с сортировкой по времени создания (самые свежие сверху).
> * python scripts/db\_cli.py scud \[ДД.ММ.ГГГГ\] \[--snapshot ID\] \[--export-xlsx NAME\] — просмотр логов СКУД и опциональный экспорт в Excel.
> * python scripts/db\_cli.py absences \[ДД.ММ.ГГГГ\] — список кадровых отклонений из 1С:ЗУП.
> * python scripts/db\_cli.py anomalies — история выявленных аномалий.
> * python scripts/db\_cli.py rules — правила базы знаний компании.
> * python scripts/db\_cli.py prompts — просмотр системных промптов из таблицы system\_prompts.
> * python scripts/db\_cli.py tools — вывод декларативного реестра действий инструментов и шаблонов кнопок.
> * python scripts/db\_cli.py sessions — просмотр активных сессионных стейтов и висящих черновиков.
> * python scripts/db\_cli.py snapshot del \[ID\] — удаление конкретного снапшота по ID.
> * python scripts/db\_cli.py snapshot del \--day \[ДД.ММ.ГГГГ\] — удаление всех снапшотов за выбранный день.
> * python scripts/db\_cli.py dump \[backup.xlsx\] — полный дамп всех таблиц базы данных в многостраничный Excel-файл.
## ---
**6\. Стратегическая дорожная карта развития (Roadmap & Backlog)**
> 1. **Масштабирование механизма эфемерного контекста на все инструменты (Rollout Roadmap):**
* **Снапшоты (snapshots):** Перевод вывода объемных таблиц срезов в эфемерный режим (is\_ephemeral \= 1), внедрение интерактивного подтверждения при удалении снапшотов по ID/дате через состояния SNAPSHOTS\_PREVIEW / SNAPSHOTS\_FOLLOWUP.
* **База знаний и аномалии (kb & anomalies):** Вывод справочников команд, правил арбитража и списков аномалий исключительно во временный слой для исключения «залипания» модели на устаревших правилах из истории сообщений.
* **Декларативная унификация в tool\_action\_registry:** Расширение схемы таблицы полями followup\_state\_type, auto\_purge\_on\_exit, guard\_question\_template для устранения хардкода имен функций в agent.py.
> 2. **Модуль анализа внутридневных перемещений («Контроль перекуров»):**
* Анализ интервалов между событиями OUT (выход) и IN (возврат) по журналу pLogData.
* Подсчет суммарного времени нахождения вне рабочего места в течение смены с отсечением технологической нормы (30–40 минут).
* Вывод аналитического блока и злостных нарушителей в отдельный лист Excel-отчета и итоговую Markdown-сводку.
> 3. **Интерактивный Task Planner & Roadmap Engine:**
* Специализированный инструмент для ведения динамических проектных планов и чек-листов с чекбоксами (\[ \] / \[x\]) прямо в диалоге с синхронизацией в таблицу tasks.
> 4. **Изолированная среда исполнения кода (Code Execution Sandbox):**
* Безопасный изолированный Docker/gVisor контейнер для динамического выполнения генерируемого моделью Python/Pandas кода на лету с возвратом stdout/stderr и графиков в контекст.
> 5. **Автоматизация расписания срезов и Telegram-алерты:**
* Автозапуск выгрузок в 12:00, 17:00 и 19:00 через systemd-таймеры с фиксацией snapshot\_time.
* Отправка мгновенных алертов о массовых сбоях турникетов (\>5%) и критических кадровых конфликтах дежурному системному администратору в Telegram.
-50
View File
@@ -1,50 +0,0 @@
# 🗺️ ДОРОЖНАЯ КАРТА И АРХИТЕКТУРНЫЙ РОАДМАП ПРОЕКТА SCUD ORION AI
## 📌 Зафиксированные вехи и концептуальные архитектурные решения
Схема веток Gitea для ближайшего развития проекта:
main (100% стабильный релиз)
│
├──► feature/tool-registry-db ──────┐ (Создание таблицы tool_action_registry в SQLite)
│ ▼
├───────────────────────────────► merge to main
│
├──► feature/interactive-buttons ───┐ (Подключение Да/Нет чипсов на фронтенде)
│ ▼
├───────────────────────────────► merge to main
│
└──► feature/ephemeral-context ─────┐ (Внедрение флагов is_ephemeral для очистки памяти LLM)
▼
merge to main
### 1. Архитектура скользящего контекста и отслеживания смены темы (Intent / Topic Drift Tracking)
* **Проблема:** При работе с длинными операциями (редактирование промптов, мастера табелей, снапшотов) оператор может отвлекаться на сторонние вопросы или уточнения. Жесткий сброс сессии уничтожает черновики, а вечное хранение засоряет память LLM.
* **Решение (Сферическое / Фундаментальное):**
1. **Семантическая оценка моделью:** Модель через системный контекст определяет, относится ли вопрос оператора к активному черновику/инструменту или разговор ушел в сторону (`topic_shift = true`).
2. **Детерминированный счетчик в бэкенде:** Бэкенд фиксирует шаги отвлечения (`idle_turns`). Пока $N < 3$, оператор может свободно общаться, не теряя висящий контекст.
3. **Вежливый перехват и выбор (Guard):** На 3-м шаге отвлечения система отвечает на текущий вопрос оператора и мягко напоминает о незавершенной транзакции кнопками `[Применить / Сохранить]` или `[Отменить и сбросить]`.
---
## 🚀 Будущие модули и запланированный функционал (Backlog)
### 2. Интерактивный модуль планирования и дорожных карт (Checklist & Task Planner Engine)
* **Концепция:** Специализированный инструмент для LLM-агента, позволяющий вести динамические проектные чек-листы и планы с интерактивными чекбоксами прямо в диалоге и базе данных.
* **Ключевые возможности:**
* Составление многоуровневых планов с чекбоксами (`[ ]` / `[x]`).
* Фиксация промежуточных комментариев и статусов выполнения по каждому пункту.
* Синхронизация задач плана с SQLite-таблицей `tasks`.
* Экспорт планов в Markdown/Excel и отображение на дашборде.
### 3. Изолированная среда выполнения и песочница кода (Code Execution Sandbox Engine)
* **Концепция:** Безопасный изолированный Docker-контейнер (или gVisor / Pyodide / nsjail окружение) для динамического выполнения кода, генерируемого моделью в процессе рассуждений и анализа данных.
* **Ключевые возможности:**
* Запуск сложных вычислений, агрегаций и статистического анализа данных СКУД на лету (Pandas / NumPy).
* Выполнение тестовых сценариев и валидация скриптов перед их сохранением/применением на проде.
* Полная изоляция от хост-системы: read-only доступ к копиям данных, ограничение памяти/CPU (cgroups), отсутствие доступа к чувствительным сетевым интерфейсам.
* Возврат результатов вычислений (stdout, stderr, артефакты, сгенерированные таблицы/графики) обратно в контекст модели.
@@ -0,0 +1,72 @@
# План реализации
## 1. Очистка от регулярок и костылей (`tool_injector.py`)
- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 4].
- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`)[cite: 4].
---
## 2. Настройка контекста и инструкций сессии (`agent.py`)
- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 4].
- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию:
- **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 4].
- **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 4].
- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 4].
- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`.
- [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-чата.
+298 -360
View File
@@ -2,28 +2,25 @@
=============================================================================== ===============================================================================
FILE: modules/web_api/llm/agent.py FILE: modules/web_api/llm/agent.py
PROJECT: SCUD Orion AI (Unified Architecture) PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm (Core Agent & Function Calling Dispatcher) MODULE: web_api / llm (Core Agent Coordinator)
ROLE: Главный оркестратор взаимодействия с Ollama LLM (Qwen 2.5), разбор вызовов ROLE: Оркестратор диалога, диспетчер реляционных узлов промпта,
инструментов (Function Calling), интеграция с декларативным реестром управление сессионными стейтами, Topic Drift Guard и очистка контекста.
действий SQLite (tool_action_registry), детерминированный Fast-Path
для подтверждений, отслеживание Topic Drift и Context Guard с кнопками.
=============================================================================== ===============================================================================
""" """
# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_IMPORTS] # =============================================================================
import os # БЛОК 1: ИМПОРТЫ И ИНИЦИАЛИЗАЦИЯ СИСТЕМНЫХ МОДУЛЕЙ
# =============================================================================
import sys import sys
import json import json
import urllib.request
import urllib.error
import logging import logging
import re import re
from typing import List, Dict, Any, Tuple, Optional from typing import List, Dict, Any, Tuple, Optional
# Импорт фасада базы данных from .db.connection import get_db_connection
from .db_tools import ( from .db_tools import (
db_get_active_system_prompt, db_get_active_system_prompt,
db_add_system_prompt, db_apply_prompt_node_action,
db_get_tool_action, db_get_tool_action,
db_get_tasks, db_get_tasks,
db_update_task_status, db_update_task_status,
@@ -33,7 +30,6 @@ from .db_tools import (
db_set_session_state, db_set_session_state,
db_get_session_state, db_get_session_state,
db_clear_session_state, db_clear_session_state,
db_increment_session_idle,
db_get_snapshots, db_get_snapshots,
db_delete_snapshots, db_delete_snapshots,
db_get_current_server_time, db_get_current_server_time,
@@ -42,15 +38,15 @@ from .db_tools import (
db_purge_ephemeral_messages, db_purge_ephemeral_messages,
db_get_stats, db_get_stats,
db_get_anomalies, db_get_anomalies,
db_get_session_states,
db_get_reference db_get_reference
) )
from .schemas import TOOLS_SCHEMA from .schemas import TOOLS_SCHEMA
from .core.calendar_utils import get_dynamic_calendar_context, parse_relative_date_ru from .core.calendar_utils import get_dynamic_calendar_context
from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed
from .core.ollama_client import call_ollama_chat
from .core.fast_path import handle_fast_path_intercept
# --- [SECTION 2: LOGGING CONFIGURATION] --- # ANCHOR[LOGGING_CONFIG]
logger = logging.getLogger("SCUD_AGENT") logger = logging.getLogger("SCUD_AGENT")
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
logger.propagate = False logger.propagate = False
@@ -61,12 +57,10 @@ if not logger.handlers:
handler.setFormatter(formatter) handler.setFormatter(formatter)
logger.addHandler(handler) logger.addHandler(handler)
OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
TEXT_MODEL = "qwen2.5:14b"
VISION_MODEL = "qwen2.5vl:7b-q8_0"
# =============================================================================
# --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] --- # ANCHOR[CHAT_PROCESSOR] # БЛОК 2: ГЛАВНЫЙ КОНВЕЙЕР ОБРАБОТКИ СООБЩЕНИЙ ЧАТА
# =============================================================================
def process_chat_message( def process_chat_message(
user_id: int, user_id: int,
user_message: str, user_message: str,
@@ -76,399 +70,343 @@ def process_chat_message(
session_id: str = "web_session_main" session_id: str = "web_session_main"
) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]: ) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
""" """
Главный конвейер обработки входящего сообщения: Главная функция обработки сообщения:
1. Fast-Path перехват подтверждений/отмен при активном session_state. 1. Очищает осиротевшие эфемерные сообщения.
2. Перехват завершения работы ('нет, закончить настройку') с автоочисткой эфемерных сообщений. 2. Перехватывает быстрые кнопки (Fast-Path).
3. Формирование системного контекста с учетом активного действия (черновика). 3. Собирает контекст и передает управление Ollama.
4. Выполнение вызванного Tool и опрос Data-Driven реестра действий. 4. Выполняет Tool Calls и управляет счетчиком Topic Drift.
5. Проверка счетчика отвлечений (idle_turns) и Context Guard на 3-м шаге.
6. Возврат кортежа: (reply_text, chat_history, action_metadata).
""" """
logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}") logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
# 3.1. Обогащение текста вложением (при наличии) # 🧹 ГАРБАДЖ-КОЛЛЕКТОР: Если стейт сессии пуст, удаляем висящие эфемерные сообщения
full_user_content = user_message
if file_context:
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}"
session_state = db_get_session_state(session_id) session_state = db_get_session_state(session_id)
user_msg_clean = user_message.lower().strip(" .!?:;") if not session_state:
db_purge_ephemeral_messages(session_id)
# --- [FAST-PATH 1: ПЕРЕХВАТ ЗАВЕРШЕНИЯ НАСТРОЙКИ С ОЧИСТКОЙ ЭФЕМЕРНОЙ ПАМЯТИ] --- full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
if user_msg_clean in ["нет, спасибо", "нет, закончить настройку", "закончить настройку", "завершить", "нет"]:
db_clear_session_state(session_id)
deleted_count = db_purge_ephemeral_messages(session_id)
logger.info(f"Завершена работа с инструментом. Удалено эфемерных сообщений: {deleted_count}")
reply_text = "Хорошо. Настройка завершена, контекст диалога чист. Чем я могу помочь дальше?"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
return reply_text, db_get_chat_history(session_id), None
# --- [FAST-PATH 2: ПЕРЕХВАТ ПОДТВЕРЖДЕНИЯ / ОТМЕНЫ ПРЕВЬЮ ПРОМПТА] --- # Перехват нажатия кнопок («Подтвердить», «Отменить», «Завершить») без вызова LLM
if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
draft_text = "" if fast_path_res:
if session_state.get("data_json") and isinstance(session_state["data_json"], dict): return fast_path_res
draft_text = session_state["data_json"].get("draft_text", "")
else:
draft_text = session_state.get("pending_data", "")
if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: # Сохранение входящего сообщения оператора
db_add_system_prompt("main_agent", draft_text)
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']}"
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
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 []
}
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']}"
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
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 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_save_chat_message(session_id, "user", full_user_content, is_ephemeral=is_user_ephemeral)
# 3.3. Извлекаем полную актуальную историю для передачи в LLM
db_history = db_get_chat_history(session_id, limit=20) db_history = db_get_chat_history(session_id, limit=20)
# =========================================================================
# БЛОК 3: ДИНАМИЧЕСКАЯ СБОРКА СИСТЕМНОГО ИНСТРУКТАЖА
# =========================================================================
dynamic_prompt_text = db_get_active_system_prompt() dynamic_prompt_text = db_get_active_system_prompt()
calendar_context = get_dynamic_calendar_context() calendar_context = get_dynamic_calendar_context()
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
# Информируем модель о наличии незавершенного действия в сессии # Извлечение текущего состояния и счетчика отвлечений (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 = "" active_state_context = ""
if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": if current_state_type == "PROMPT_PREVIEW":
active_state_context = ( active_state_context = (
"\n[АКТИВНОЕ ДЕЙСТВИЕ В СЕССИИ]\n" "\n[ВНИМАНИЕ: АКТИВНЫЙ РЕЖИМ ПРЕДПРОСМОТРА ПРОМПТА]\n"
"В данный момент оператор рассматривает подготовленный черновик системного промпта.\n" "- Сейчас открыт предпросмотр изменения системного промпта.\n"
"- Если оператор просит продолжить правки или уточняет детали по черновику — продолжай работу с ним.\n" "- Если пользователь просит изменить, скорректировать формулировку или удалить пункт — вызови инструмент db_prompt_node_edit.\n"
"- Если оператор переключился на другую тему или вызвал другой инструмент — выполни его команду штатно.\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 = ( system_prompt_content = (
f"Ты — интеллектуальный ассистент SCUD Orion AI. " f"Ты — интеллектуальный ассистент SCUD Orion AI. "
f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n" f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n"
f"СТРОГИЕ ПРАВИЛА СТИЛЯ:\n"
f"- Запрещено использовать панибратские или шутливые обращения. Отвечай профессионально и строго по существу.\n\n"
f"[ОКРУЖЕНИЕ]\n" f"[ОКРУЖЕНИЕ]\n"
f"- Пользователь: {user_info}\n" f"- Пользователь: {user_info}\n"
f"- {calendar_context}\n" f"- {calendar_context}\n"
f"{active_state_context}\n" f"{active_state_context}\n"
f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\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"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n"
f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n" f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n\n"
f"4. Никогда не симулируй выполнение функций в виде обычного текста. Если требуется действие — сразу вызывай соответствующий инструмент.\n\n"
f"[ПРИМЕРЫ ВЫЗОВА ИНСТРУМЕНТОВ]:\n"
f"- Пользователь: 'добавь пункт 3.4. Работать от сюда и до заката.' -> Вызов: db_preview_prompt_merge(prompt_text='3.4. Работать от сюда и до заката.')\n"
f"- Пользователь: 'удали пункт 3.4' -> Вызов: db_preview_prompt_merge(prompt_text='3.4')\n"
f"- Пользователь: 'покажи системный промпт' -> Вызов: db_get_system_prompt()\n"
f"- Пользователь: 'покажи мои задачи' -> Вызов: db_get_tasks()\n\n"
f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}" f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}"
) )
llm_options = {
"num_predict": 8192,
"num_ctx": 8192,
"temperature": 0.1,
"repeat_penalty": 1.1,
"presence_penalty": 0.5,
"top_p": 0.9
}
user_msg_object = {"role": "user", "content": full_user_content} user_msg_object = {"role": "user", "content": full_user_content}
# --- [SECTION 4: ROUTING & OLLAMA PAYLOAD] --- # ANCHOR[PAYLOAD_BUILD]
if image_b64:
user_msg_object["images"] = [image_b64]
messages = [
{"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву без отсебятины."},
user_msg_object
]
payload = {"model": VISION_MODEL, "messages": messages, "stream": False, "options": llm_options}
else:
clean_db_history = [dict(m) for m in db_history]
for m in clean_db_history:
m.pop("images", None)
messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object]
payload = {"model": TEXT_MODEL, "messages": messages, "tools": TOOLS_SCHEMA, "stream": False, "options": llm_options}
# --- [SECTION 5: EXECUTION & TOOL ROUTING] --- # ANCHOR[TOOL_ROUTER] # =========================================================================
# БЛОК 4: ВЫЗОВ НЕЙРОСЕТИ И МАРШРУТИЗАЦИЯ FUNCTION CALLING
# =========================================================================
try: try:
req = urllib.request.Request( # Режим Vision (OCR)
OLLAMA_URL, if image_b64:
data=json.dumps(payload).encode("utf-8"), user_msg_object["images"] = [image_b64]
headers={"Content-Type": "application/json"} messages = [
) {"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву."},
with urllib.request.urlopen(req) as response: user_msg_object
res_data = json.loads(response.read().decode("utf-8")) ]
msg = res_data.get("message", {}) msg = call_ollama_chat(messages, is_vision=True)
tool_calls = msg.get("tool_calls", []) else:
raw_text_content = msg.get("content", "") # Текстовый диалог
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)})") if tool_calls:
messages.append(msg) logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})")
messages.append(msg)
for tool in tool_calls: tool_names_called = []
fn_name = tool["function"]["name"]
fn_args = tool["function"].get("arguments", {})
logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
tool_result_content = ""
action_cfg = db_get_tool_action(fn_name) # -----------------------------------------------------------------
# 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": # Переводим сессию в PROMPT_FOLLOWUP для отслеживания Topic Drift
if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
draft_text = "" db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
if session_state.get("data_json") and isinstance(session_state["data_json"], dict): return reply_text, db_get_chat_history(session_id), None
draft_text = session_state["data_json"].get("draft_text", "")
else:
draft_text = session_state.get("pending_data", "")
db_add_system_prompt("main_agent", draft_text) # --- 4.3. Реляционное изменение промпта (ADD / UPDATE / DELETE) ---
db_clear_session_state(session_id) elif fn_name == "db_prompt_node_edit":
action = fn_args.get("action", "ADD").upper()
if action_cfg and action_cfg.get("bypass_llm"): sec_id = int(fn_args.get("section_id", 2))
reply_text = action_cfg["success_template"] itm_id = int(fn_args.get("item_id", 1))
if action_cfg.get("follow_up_question"): content = fn_args.get("content", "").strip()
reply_text += f"\n\n{action_cfg['follow_up_question']}" baseline_prompt = db_get_active_system_prompt()
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip() with get_db_connection() as temp_conn:
is_eph = action_cfg.get("is_ephemeral", 1) temp_cursor = temp_conn.cursor()
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph) temp_cursor.execute("""
return reply_text, db_get_chat_history(session_id), { SELECT section_id, item_id, content
"type": action_cfg.get("action_type"), FROM system_prompt_nodes
"buttons": action_cfg.get("buttons", []) WHERE prompt_name = 'main_agent' AND is_active = 1
} ORDER BY section_id, item_id
tool_result_content = json.dumps({"status": "success"}, ensure_ascii=False) """)
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: else:
err_reply = "Нет активного превью для подтверждения." draft_lines.append(f" {s_id}.{i_id}. {txt}")
db_save_chat_message(session_id, "assistant", err_reply, is_ephemeral=1) merged_prompt = "\n".join(draft_lines)
return err_reply, db_get_chat_history(session_id), None
elif fn_name == "db_cancel_prompt_preview": # Формирование HTML Diff с подсветкой
db_clear_session_state(session_id) diff_lines = []
if action_cfg and action_cfg.get("bypass_llm"): curr_sec = None
reply_text = action_cfg["success_template"] display_nodes = dict(nodes_dict)
if action_cfg.get("follow_up_question"): if action != "DELETE":
reply_text += f"\n\n{action_cfg['follow_up_question']}" display_nodes[(sec_id, itm_id)] = content
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
is_eph = action_cfg.get("is_ephemeral", 1)
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph)
return reply_text, db_get_chat_history(session_id), {
"type": action_cfg.get("action_type"),
"buttons": action_cfg.get("buttons", [])
}
tool_result_content = json.dumps({"status": "cancelled"}, ensure_ascii=False)
elif fn_name == "db_get_snapshots": for (s_id, i_id), txt in sorted(display_nodes.items()):
snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message) if i_id == 0:
tool_result_content = json.dumps(snapshots_res, ensure_ascii=False) if curr_sec is not None:
diff_lines.append("")
elif fn_name == "db_get_current_server_time": diff_lines.append(f"{s_id}. {txt}")
tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False) curr_sec = s_id
else:
elif fn_name == "db_get_tasks": if s_id == sec_id and i_id == itm_id:
tool_result_content = json.dumps(db_get_tasks(user_id), ensure_ascii=False) if action == "DELETE":
line_str = f' <span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{s_id}.{i_id}. {txt} [УДАЛЕНИЕ]</span>'
elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]: else:
tool_result_content = json.dumps({"system_prompt": db_get_active_system_prompt()}, ensure_ascii=False) line_str = f' <span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{s_id}.{i_id}. {txt}</span>'
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']}"
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
is_eph = action_cfg.get("is_ephemeral", 1)
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph)
return reply_text, db_get_chat_history(session_id), {
"type": action_cfg.get("action_type"),
"buttons": action_cfg.get("buttons", [])
}
tool_result_content = json.dumps(res, ensure_ascii=False)
elif fn_name == "db_get_reference":
tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
# --- [SECTION 6: PROMPT MERGE & PREVIEW ENGINE] --- # ANCHOR[PROMPT_MERGE_LOGIC]
elif fn_name == "db_preview_prompt_merge":
proposed_text = fn_args.get("prompt_text") or fn_args.get("proposed_prompt") or ""
if isinstance(fn_args, str):
proposed_text = fn_args
current_prompt = db_get_active_system_prompt()
user_msg_lower = user_message.lower()
# 1. Обработка удаления пункта
if any(w in user_msg_lower for w in ["удали", "стереть", "убрать", "вырежи", "удалить"]):
target_num_match = re.search(r'\d+(\.\d+)*', user_message)
target_num = target_num_match.group(0) if target_num_match else ""
lines = current_prompt.splitlines()
if target_num:
new_lines = [line for line in lines if not line.strip().startswith(f"{target_num}.")]
else: else:
new_lines = lines line_str = f" {s_id}.{i_id}. {txt}"
proposed_text = "\n".join(new_lines) diff_lines.append(line_str)
# 2. Обработка добавления / изменения пункта diff_html = "\n".join(diff_lines)
elif proposed_text:
if len(proposed_text) < 500:
clean_item = proposed_text.strip()
for prefix in ["добавь пункт", "добавить пункт", "вставь пункт", "добавь"]:
if prefix in clean_item.lower():
clean_item = re.sub(prefix, "", clean_item, flags=re.IGNORECASE).strip(" .:")
lines = current_prompt.splitlines() # Фиксация предпросмотра в session_states
new_lines = [] db_set_session_state(session_id, "PROMPT_PREVIEW", {
inserted = False "draft_text": merged_prompt,
for line in lines: "action": action,
new_lines.append(line) "section_id": sec_id,
if "3.3." in line and not inserted: "item_id": itm_id,
item_str = clean_item if re.match(r'^\d+\.\d+\.', clean_item) else f"3.4. {clean_item}" "content": content,
new_lines.append(f" {item_str}") "idle_turns": 0
inserted = True })
if not inserted:
new_lines.append(f" {clean_item}")
proposed_text = "\n".join(new_lines)
# Сохраняем черновик в структурированном виде с idle_turns = 0 with get_db_connection() as conn_fix:
state_payload = { conn_fix.cursor().execute("""
"draft_text": proposed_text, UPDATE chat_messages
"idle_turns": 0 SET is_ephemeral = 1
} WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user')
db_set_session_state(session_id, "PROMPT_PREVIEW", state_payload) """, (session_id,))
conn_fix.commit()
preview_reply = (
f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n"
f"{proposed_text}\n\n"
f"Для применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)."
)
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id), {
"type": "PROMPT_PREVIEW",
"buttons": [
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"}
]
}
elif fn_name == "db_get_rules": preview_reply = (
tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) f"Предпросмотр изменений системного промпта:\n\n"
f"{diff_html}\n\n"
elif fn_name == "db_add_task": f"Для применения подтвердите действие, отредактируйте или отмените."
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)
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
elif fn_name == "db_update_task_status": return preview_reply, db_get_chat_history(session_id), {
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")) "type": "PROMPT_PREVIEW",
tool_result_content = json.dumps(res, ensure_ascii=False) "raw_draft": merged_prompt,
"baseline_prompt": baseline_prompt,
elif fn_name == "db_delete_task":
res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper())
if action_cfg and action_cfg.get("bypass_llm"):
reply_text = action_cfg["success_template"]
if action_cfg.get("follow_up_question"):
reply_text += f"\n\n{action_cfg['follow_up_question']}"
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
is_eph = action_cfg.get("is_ephemeral", 1)
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph)
return reply_text, db_get_chat_history(session_id), {
"type": action_cfg.get("action_type"),
"buttons": action_cfg.get("buttons", [])
}
tool_result_content = json.dumps(res, ensure_ascii=False)
messages.append({"role": "tool", "content": tool_result_content})
# --- [SECTION 7: SECONDARY LLM PASS & CONTEXT GUARD] --- # ANCHOR[SECONDARY_PASS]
second_payload = {"model": TEXT_MODEL, "messages": messages, "stream": False, "options": llm_options}
sec_req = urllib.request.Request(OLLAMA_URL, data=json.dumps(second_payload).encode("utf-8"), headers={"Content-Type": "application/json"})
with urllib.request.urlopen(sec_req) as sec_response:
sec_res_data = json.loads(sec_response.read().decode("utf-8"))
raw_content = sec_res_data.get("message", {}).get("content", "").strip().replace("**", "").replace("*", "")
final_content = clean_raw_tool_tags(clean_output(raw_content))
# Если была активна фоновая транзакция, инкрементируем счетчик Topic Shift
if session_state and session_state.get("state_type") == "PROMPT_PREVIEW":
idle_count = db_increment_session_idle(session_id)
logger.info(f"Выполнен инструмент вне черновика. Текущий idle_turns: {idle_count}")
if idle_count >= 3:
guard_note = "\n\nНапоминание: У вас остался непримененный черновик системного промпта. Сохранить его или сбросить?"
final_content += guard_note
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=1)
return final_content, db_get_chat_history(session_id), {
"type": "PROMPT_PREVIEW_GUARD",
"buttons": [
{"label": "Применить черновик", "value": "подтверждаю", "style": "primary"},
{"label": "Сбросить черновик", "value": "отмена", "style": "danger"}
]
}
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0)
return final_content, db_get_chat_history(session_id), None
# Если вызовов инструментов не было (обычный текстовый диалог)
raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "")
content_str = clean_raw_tool_tags(clean_output(raw_str))
final_reply = content_str or "Запрос обработан."
if session_state and session_state.get("state_type") == "PROMPT_PREVIEW":
idle_count = db_increment_session_idle(session_id)
logger.info(f"Текстовый диалог вне черновика. Текущий idle_turns: {idle_count}")
if idle_count >= 3:
guard_note = "\n\nНапоминание: У вас остался непримененный черновик системного промпта. Сохранить его или сбросить?"
final_reply += guard_note
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=1)
return final_reply, db_get_chat_history(session_id), {
"type": "PROMPT_PREVIEW_GUARD",
"buttons": [ "buttons": [
{"label": "Применить черновик", "value": "подтверждаю", "style": "primary"}, {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
{"label": "Сбросить черновик", "value": "отмена", "style": "danger"} {"label": "Отменить", "value": "отмена", "style": "danger"},
{"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
] ]
} }
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) # --- 4.4. Сервисные инструменты ---
return final_reply, db_get_chat_history(session_id), None elif fn_name == "db_get_snapshots":
snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message)
tool_result_content = json.dumps(snapshots_res, ensure_ascii=False)
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
# =====================================================================
# БЛОК 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: except Exception as ex:
logger.exception(f"Непредвиденная ошибка: {ex}") logger.exception(f"Непредвиденная ошибка агента: {ex}")
error_reply = f"Внутренняя ошибка сервера: {ex}" error_reply = f"Внутренняя ошибка сервера: {ex}"
return error_reply, db_get_chat_history(session_id), None return error_reply, db_get_chat_history(session_id), None
+106
View File
@@ -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
+61
View File
@@ -0,0 +1,61 @@
"""
===============================================================================
FILE: modules/web_api/llm/core/ollama_client.py
ROLE: Транспортный клиент HTTP взаимодействия с локальным API Ollama.
===============================================================================
"""
# ANCHOR[OLLAMA_CLIENT_IMPORTS]
import json
import urllib.request
import urllib.error
import logging
from typing import Dict, Any, Optional
logger = logging.getLogger("OLLAMA_CLIENT")
OLLAMA_URL = "http://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", {})
+74
View File
@@ -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'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-200">{line}</span>')
else:
diff_lines.append(line)
return "\n".join(diff_lines)
+28 -17
View File
@@ -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 re
import json
import logging import logging
from typing import List, Dict, Any from typing import List, Dict, Any
logger = logging.getLogger("TOOL_INJECTOR") logger = logging.getLogger("TOOL_INJECTOR")
def clean_raw_tool_tags(text: str) -> str: def clean_raw_tool_tags(text: str) -> str:
"""Удаляет сырые теги вызова инструментов, если модель случайно вывела их в текст."""
if not text: if not text:
return "" return ""
text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*(</tool_call>)?', '', text) text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*(</tool_call>)?', '', text)
@@ -14,26 +23,28 @@ def clean_raw_tool_tags(text: str) -> str:
text = re.sub(r'</tool_call>', '', text) text = re.sub(r'</tool_call>', '', text)
return text.strip() return text.strip()
def clean_output(text: str) -> str: def clean_output(text: str) -> str:
"""Удаляет слова-паразиты и склейки в начале ответа."""
if not text: if not text:
return text return text
artifacts = ["почемучто", "почто", "почему что"]
lower_text = text.lower() artifacts = [
"почемучка", "почемучка,", "почемучка!", "почемучка?",
"почемучто", "почто", "почему что", "почему-то",
"здравствуйте!", "привет!"
]
cleaned = text.strip()
for art in artifacts: for art in artifacts:
if lower_text.startswith(art): if cleaned.lower().startswith(art):
text = text[len(art):].lstrip(",.!?:; -") cleaned = cleaned[len(art):].lstrip(",.!?:; -")
return text.strip()
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]]: 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 Проходной фильтр: все решения о вызове инструментов принимает исключительно LLM.
if '{"name":' in raw_text_content or '<tool_call>' in raw_text_content: """
try:
match = re.search(r'\{"name":\s*"([^"]+)",\s*"(?:params|arguments|properties)":\s*(\{.*?\})\}', raw_text_content)
if match:
fn_name = match.group(1)
fn_args = json.loads(match.group(2))
return [{"function": {"name": fn_name, "arguments": fn_args}}]
except Exception as parse_err:
logger.debug(f"Ошибка парсинга сырого tool call: {parse_err}")
return tool_calls return tool_calls
+163 -117
View File
@@ -1,11 +1,13 @@
""" """
===============================================================================
FILE: modules/web_api/llm/db/db_prompts.py FILE: modules/web_api/llm/db/db_prompts.py
PROJECT: SCUD Orion AI PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / db MODULE: web_api / llm / db
ROLE: Управление системными промптами, правилами базы знаний, ROLE: Реляционное управление системным промптом (таблица system_prompt_nodes),
декларативным реестром действий инструментов (tool_action_registry) базой знаний, реестром действий и сессионными стейтами.
и расширенным сессионным состоянием (session_states + idle_turns). ===============================================================================
""" """
import re
import json import json
import logging import logging
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
@@ -14,18 +16,115 @@ from .connection import get_db_connection
logger = logging.getLogger("DB_PROMPTS") logger = logging.getLogger("DB_PROMPTS")
def db_get_active_system_prompt() -> str: def init_prompt_nodes_table():
"""Извлекает актуальный активный системный промпт из SQLite.""" """Создает реляционную таблицу узлов промпта и заполняет базовыми данными."""
conn = get_db_connection() with get_db_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT prompt_text FROM system_prompts WHERE is_active = 1 ORDER BY id DESC LIMIT 1") cursor.execute("""
row = cursor.fetchone() CREATE TABLE IF NOT EXISTS system_prompt_nodes (
conn.close() id INTEGER PRIMARY KEY AUTOINCREMENT,
return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI." prompt_name TEXT DEFAULT 'main_agent',
section_id INTEGER NOT NULL,
item_id INTEGER NOT NULL,
content TEXT NOT NULL,
is_active INTEGER DEFAULT 1,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(prompt_name, section_id, item_id)
);
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);")
cursor.execute("SELECT COUNT(*) FROM system_prompt_nodes WHERE prompt_name = 'main_agent'")
if cursor.fetchone()[0] == 0:
seed_nodes = [
# Раздел 1
(1, 0, "РОЛЬ И ЗАДАЧИ АССИСТЕНТА"),
(1, 1, "Управление бэклогом задач проекта (через db_get_tasks, db_add_task, db_update_task_status, db_delete_task)."),
(1, 2, "Консультация по правилам и арбитражу кадровых данных/СКУД из базы знаний (через db_get_rules)."),
(1, 3, "Предоставление справки о возможностях и примерах команд (СТРОГО через db_get_reference)."),
(1, 4, "Просмотр аномалий СКУД ⟷ 1С (СТРОГО через db_get_anomalies)."),
(1, 5, "Поддержка диалога с операторами и администраторами системы."),
(1, 7, "Работа с логами, снапшотами и срезами СКУД (через db_get_snapshots)."),
# Раздел 2
(2, 0, "ПРАВИЛА ВЫЗОВА ИНСТРУМЕНТОВ И ДАТ"),
(2, 1, "ОБЯЗАТЕЛЬНЫЙ ПРЕВЬЮ-МЕРДЖ: Категорически ЗАПРЕЩЕНО изменять промпт напрямую! При ЛЮБОМ запросе пользователя на изменение системного промпта Ты ОБЯЗАН вызвать инструмент db_prompt_node_edit."),
(2, 2, "БЕЗУСЛОВНОЕ ПОДТВЕРЖДЕНИЕ ПРЕВЬЮ: Инструмент db_confirm_prompt_preview вызывается СТРОГО после того, как пользователь напишет 'подтверждаю', 'да', 'сохраняй'."),
(2, 3, "ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧ: При запросе на удаление задач КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО сразу вызывать db_delete_task! Ты ОБЯЗАН сначала спросить пользователя подтверждение."),
(2, 4, "ИСПОЛЬЗОВАНИЕ КАЛЕНДАРЯ: При любых вопросах про даты БЕРИ ТОЧНУЮ ДАТУ ИЗ [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА] В НАЧАЛЕ КОНТЕКСТА."),
(2, 5, "СТРОГИЙ ВЫЗОВ АНОМАЛИЙ: При запросах аномалий или нарушений Ты ОБЯЗАН вызвать инструмент db_get_anomalies."),
(2, 6, "СТРОГИЙ ВЫЗОВ СПРАВОЧНИКА: При запросах о возможностях, примерах запросов или командах Ты ОБЯЗАН вызывать db_get_reference."),
(2, 7, "Запрещено писать названия функций или код вызова текстом на экран."),
(2, 8, "СТРОГИЙ ВЫЗОВ СНАПШОТОВ: При ЛЮБЫХ запросах про снапшоты, срезы или логи СКУД за дату/день недели Ты ОБЯЗАН вызывать инструмент db_get_snapshots."),
# Раздел 3
(3, 0, "ПРАВИЛА СТИЛЯ"),
(3, 1, "Никогда не начинай ответ со склеек или слов-паразитов."),
(3, 2, "Отвечай в чистом текстовом формате (plain text) без спецсимволов или ###, если прямо не попросили."),
(3, 3, "Сохраняй инженерный, лаконичный и профессиональный стиль.")
]
cursor.executemany("""
INSERT OR IGNORE INTO system_prompt_nodes (prompt_name, section_id, item_id, content)
VALUES ('main_agent', ?, ?, ?)
""", seed_nodes)
conn.commit()
def db_get_active_system_prompt(prompt_name: str = "main_agent") -> str:
"""Собирает структурированный текст промпта из реляционной таблицы узлов."""
init_prompt_nodes_table()
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT section_id, item_id, content
FROM system_prompt_nodes
WHERE prompt_name = ? AND is_active = 1
ORDER BY section_id ASC, item_id ASC
""", (prompt_name,))
rows = cursor.fetchall()
if not rows:
return "Ты — ИИ-ассистент SCUD Orion AI."
lines = []
current_section = None
for sec_id, itm_id, content in rows:
if itm_id == 0:
if current_section is not None:
lines.append("")
lines.append(f"{sec_id}. {content}")
current_section = sec_id
else:
lines.append(f" {sec_id}.{itm_id}. {content}")
return "\n".join(lines)
def db_apply_prompt_node_action(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent"):
"""Прямое добавление, изменение или удаление узла в БД."""
init_prompt_nodes_table()
with get_db_connection() as conn:
cursor = conn.cursor()
action_clean = action.upper()
if action_clean in ["ADD", "UPDATE"]:
cursor.execute("""
INSERT INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active, updated_at)
VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(prompt_name, section_id, item_id) DO UPDATE SET
content = excluded.content,
is_active = 1,
updated_at = CURRENT_TIMESTAMP
""", (prompt_name, section_id, item_id, content))
elif action_clean == "DELETE":
cursor.execute("""
DELETE FROM system_prompt_nodes
WHERE prompt_name = ? AND section_id = ? AND item_id = ?
""", (prompt_name, section_id, item_id))
conn.commit()
def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]: def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]:
"""Получение шаблона, кнопок и настроек эфемерности инструмента из SQLite."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
@@ -44,33 +143,7 @@ def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]:
return None return None
def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
"""Сохраняет новую версию системного промпта и активирует её."""
try:
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("BEGIN IMMEDIATE;")
cursor.execute("SELECT id FROM system_prompts WHERE name = ?", (name,))
existing = cursor.fetchone()
if existing:
cursor.execute(
"UPDATE system_prompts SET prompt_text = ?, is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE name = ?",
(prompt_text, name)
)
else:
cursor.execute(
"INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)",
(name, prompt_text)
)
conn.commit()
return {"status": "success", "message": "Системный промпт успешно обновлен"}
except Exception as e:
logger.error(f"Ошибка при сохранении промпта в БД: {e}")
return {"status": "error", "error": str(e)}
def db_get_rules() -> List[Dict[str, Any]]: def db_get_rules() -> List[Dict[str, Any]]:
"""Извлекает список глобальных правил компании из ai_knowledge_base."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC") cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC")
@@ -79,23 +152,11 @@ def db_get_rules() -> List[Dict[str, Any]]:
return [dict(r) for r in rows] return [dict(r) for r in rows]
# =============================================================================
# РАБОТА С СЕССИОННЫМИ СОСТОЯНИЯМИ, ЧЕРНОВИКАМИ И СЧЕТЧИКОМ ОТВЛЕЧЕНИЙ (IDLE_TURNS)
# =============================================================================
def db_set_session_state(session_id: str, state_type: str, data: Any) -> None: def db_set_session_state(session_id: str, state_type: str, data: Any) -> None:
""" """Сохраняет состояние сессии в SQLite."""
Сохраняет состояние сессии в SQLite.
Если передан dict/list — автоматически сериализует его в JSON-строку.
"""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
payload_str = json.dumps(data, ensure_ascii=False) if isinstance(data, (dict, list)) else (str(data) if data is not None else "")
if isinstance(data, (dict, list)):
payload_str = json.dumps(data, ensure_ascii=False)
else:
payload_str = str(data) if data is not None else ""
cursor.execute(""" cursor.execute("""
INSERT INTO session_states (session_id, state_type, pending_data, updated_at) INSERT INTO session_states (session_id, state_type, pending_data, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, ?, CURRENT_TIMESTAMP)
@@ -109,63 +170,23 @@ def db_set_session_state(session_id: str, state_type: str, data: Any) -> None:
def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]: def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]:
"""
Извлекает состояние сессии из SQLite.
Если в pending_data лежит валидный JSON-объект — парсит его в data_json.
"""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute("SELECT session_id, state_type, pending_data, updated_at FROM session_states WHERE session_id = ?", (session_id,))
"SELECT session_id, state_type, pending_data, updated_at FROM session_states WHERE session_id = ?",
(session_id,)
)
row = cursor.fetchone() row = cursor.fetchone()
conn.close() conn.close()
if not row: if not row:
return None return None
res = dict(row) res = dict(row)
raw_data = res.get("pending_data") or "" raw_data = res.get("pending_data") or ""
try: try:
if raw_data.strip().startswith("{") or raw_data.strip().startswith("["): res["data_json"] = json.loads(raw_data) if raw_data.strip().startswith(("{", "[")) else None
res["data_json"] = json.loads(raw_data)
else:
res["data_json"] = None
except Exception: except Exception:
res["data_json"] = None res["data_json"] = None
return res return res
def db_increment_session_idle(session_id: str) -> int:
"""
Инкрементирует счетчик idle_turns для активного черновика сессии.
Возвращает обновленное значение счетчика отвлечений.
"""
state = db_get_session_state(session_id)
if not state:
return 0
data_meta = state.get("data_json")
if isinstance(data_meta, dict):
current_turns = data_meta.get("idle_turns", 0) + 1
data_meta["idle_turns"] = current_turns
db_set_session_state(session_id, state["state_type"], data_meta)
return current_turns
else:
# Если ранее данные были сохранены обычной строкой
new_meta = {
"draft_text": state.get("pending_data", ""),
"idle_turns": 1
}
db_set_session_state(session_id, state["state_type"], new_meta)
return 1
def db_clear_session_state(session_id: str) -> None: def db_clear_session_state(session_id: str) -> None:
"""Сбрасывает и удаляет активное состояние сессии."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
@@ -173,28 +194,10 @@ def db_clear_session_state(session_id: str) -> None:
conn.close() conn.close()
def db_get_session_states() -> Dict[str, Any]:
"""Возвращает реестр всех активных сессий."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT session_id, state_type, updated_at FROM session_states")
rows = cursor.fetchall()
conn.close()
return {"status": "success", "active_sessions": [dict(r) for r in rows]}
# =============================================================================
# СЛУЖЕБНАЯ ДИАГНОСТИКА И СТАТИСТИКА
# =============================================================================
def db_get_stats() -> Dict[str, Any]: def db_get_stats() -> Dict[str, Any]:
"""Возвращает статистику по количеству записей в таблицах."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
tables = [ tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompt_nodes', 'session_states', 'tasks']
'scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history',
'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks'
]
stats = {} stats = {}
for t in tables: for t in tables:
try: try:
@@ -207,7 +210,6 @@ def db_get_stats() -> Dict[str, Any]:
def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]: def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]:
"""Извлекает журнал зафиксированных аномалий."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history" query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history"
@@ -224,7 +226,6 @@ def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[s
def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]: def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]:
"""Извлекает справочные команды и примеры подсказок."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
query = "SELECT category, title, example_prompt, description FROM system_reference" query = "SELECT category, title, example_prompt, description FROM system_reference"
@@ -236,4 +237,49 @@ def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]:
cursor.execute(query, params) cursor.execute(query, params)
rows = cursor.fetchall() rows = cursor.fetchall()
conn.close() conn.close()
return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]} return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]}
def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
"""Надежный парсер: восстанавливает разделы и пункты с авто-выравниванием отступов."""
try:
init_prompt_nodes_table()
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM system_prompt_nodes WHERE prompt_name = ?", (name,))
current_sec = 1
current_itm = 0
for raw_line in prompt_text.splitlines():
clean_line = re.sub(r'<[^>]+>', '', raw_line).strip()
if not clean_line:
continue
# 1. Проверяем подпункт (например: "1.8. Текст", "1.8 Текст", "1. 8 Текст")
sub_match = re.match(r'^(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)$', clean_line)
# 2. Проверяем заголовок раздела (например: "1. РОЛЬ И ЗАДАЧИ", "1 РОЛЬ И ЗАДАЧИ")
sec_match = re.match(r'^(\d+)[\.\s\:\-]+(.*)$', clean_line)
if sub_match:
current_sec = int(sub_match.group(1))
current_itm = int(sub_match.group(2))
content = sub_match.group(3).strip()
elif sec_match and not any(c.islower() for c in sec_match.group(2)[:15]):
# Заголовок раздела (обычно капсом)
current_sec = int(sec_match.group(1))
current_itm = 0
content = sec_match.group(2).strip()
else:
current_itm += 1
content = clean_line
cursor.execute("""
INSERT OR REPLACE INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active)
VALUES (?, ?, ?, ?, 1)
""", (name, current_sec, current_itm, content))
conn.commit()
return {"status": "success", "message": "Системный промпт успешно сохранен по узлам"}
except Exception as e:
logger.error(f"Ошибка при разборе промпта в узлы: {e}")
return {"status": "error", "error": str(e)}
+1 -2
View File
@@ -9,13 +9,12 @@ from .db.db_snapshots import db_get_snapshots, db_delete_snapshots
from .db.db_prompts import ( from .db.db_prompts import (
db_get_active_system_prompt, db_get_active_system_prompt,
db_add_system_prompt, db_add_system_prompt,
db_apply_prompt_node_action,
db_get_tool_action, db_get_tool_action,
db_get_rules, db_get_rules,
db_set_session_state, db_set_session_state,
db_get_session_state, db_get_session_state,
db_clear_session_state, db_clear_session_state,
db_increment_session_idle,
db_get_session_states,
db_get_stats, db_get_stats,
db_get_anomalies, db_get_anomalies,
db_get_reference db_get_reference
+46 -59
View File
@@ -1,4 +1,39 @@
TOOLS_SCHEMA = [ 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", "type": "function",
"function": { "function": {
@@ -82,18 +117,18 @@ TOOLS_SCHEMA = [
} }
}, },
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "db_delete_snapshots", "name": "db_delete_snapshots",
"description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.", "description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"}, "snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"},
"day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"} "day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"}
}
} }
} }
}
}, },
{ {
"type": "function", "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": {}}
}
}
] ]
+26 -250
View File
@@ -2,68 +2,37 @@
=============================================================================== ===============================================================================
FILE: modules/web_api/main.py FILE: modules/web_api/main.py
PROJECT: SCUD Orion AI (Unified Repository) PROJECT: SCUD Orion AI (Unified Repository)
MODULE: web_api (FastAPI REST Server & Context Management) MODULE: web_api (Main Application Entry Point)
ROLE: Главный шлюз веб-интерфейса, авторизация пользователей (JWT/Bcrypt), ROLE: Инициализация FastAPI приложения, подключение роутеров и статики.
маршрутизация диалогов с LLM, OCR-парсинг файлов и управление задачами.
AI-CONTEXT-ANCHORS & INVARIANTS:
- ANCHOR[SYS_PATH]: Добавляет директорию модуля в sys.path для корректных импортов
независимо от рабочей директории запуска (root или web_api).
- ANCHOR[STATIC_MOUNT]: Рассчитывает абсолютный путь к папке static/ для надежного
рендеринга интерфейса и ассетов (css/js/favicon).
- ANCHOR[AUTH_JWT]: Изолирует персональные пространства задач по user_id (sub).
- ANCHOR[CHAT_PIPELINE]: Оркестрирует пайплайн парсинга вложений (file_parser) и
генерации ответов LLM (agent.process_chat_message).
DEPENDENCIES:
- modules/web_api/llm/agent.py (process_chat_message)
- modules/web_api/llm/db_tools.py (db_get_tasks, DB_PATH)
- modules/web_api/llm/file_parser.py (extract_text_from_file)
=============================================================================== ===============================================================================
""" """
# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_PATH] # ANCHOR[APP_INIT_IMPORTS]
import os import os
import sys import sys
import json
import sqlite3
import logging import logging
import urllib.request
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
# Гарантируем корректный импорт подмодулей web_api независимо от точки запуска
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
if CURRENT_DIR not in sys.path: if CURRENT_DIR not in sys.path:
sys.path.insert(0, CURRENT_DIR) sys.path.insert(0, CURRENT_DIR)
import jwt from fastapi import FastAPI, HTTPException
from passlib.context import CryptContext
from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Form
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from pydantic import BaseModel
# Внутренние модули LLM и БД from routers.auth import router as auth_router
from llm.agent import process_chat_message from routers.admin import router as admin_router
from llm.db_tools import db_get_tasks, DB_PATH from routers.tasks import router as tasks_router
from llm.file_parser import extract_text_from_file from routers.chat import router as chat_router
# --- [SECTION 2: CONFIGURATION & SECURITY] --- # ANCHOR[AUTH_CONFIG] # ANCHOR[APP_CONFIG]
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s", format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler()] handlers=[logging.StreamHandler()]
) )
JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
ALGORITHM = "HS256"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
STATIC_DIR = os.path.join(CURRENT_DIR, "static") STATIC_DIR = os.path.join(CURRENT_DIR, "static")
app = FastAPI(title="SCUD Orion AI Context API", version="2.5") app = FastAPI(title="SCUD Orion AI Context API", version="2.5")
@@ -79,57 +48,15 @@ async def validation_exception_handler(request, exc):
content={"detail": exc.errors(), "body": str(exc)} content={"detail": exc.errors(), "body": str(exc)}
) )
# --- [SECTION 3: DATABASE & TOKEN HELPERS] --- # ANCHOR[DB_HELPERS] # ANCHOR[ROUTER_REGISTRATION]
def get_db(): app.include_router(auth_router)
"""Создает безопасное соединение с SQLite БД модуля.""" app.include_router(admin_router)
conn = sqlite3.connect(DB_PATH) app.include_router(tasks_router)
conn.row_factory = sqlite3.Row app.include_router(chat_router)
return conn
def create_access_token(user_id: int, username: str, is_admin: bool) -> str: # ANCHOR[ROOT_STATIC_ROUTES]
payload = {
"sub": str(user_id),
"username": username,
"is_admin": is_admin,
"exp": datetime.utcnow() + timedelta(days=30)
}
return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
try:
token = credentials.credentials
payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM])
user_id = int(payload.get("sub"))
username = payload.get("username")
is_admin = bool(payload.get("is_admin", False))
return {"id": user_id, "username": username, "is_admin": is_admin}
except Exception as e:
logging.warning(f"Auth error: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Недействительный или просроченный токен авторизации",
headers={"WWW-Authenticate": "Bearer"},
)
# Pydantic-схемы валидации запросов
class AuthRequest(BaseModel):
username: str
password: str
class CreateUserRequest(BaseModel):
username: str
password: str
full_name: Optional[str] = None
is_admin: Optional[bool] = False
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
# --- [SECTION 4: STATIC FILES & SPA ROUTES] --- # ANCHOR[STATIC_MOUNT]
@app.get("/") @app.get("/")
def read_root(): def read_root():
"""Отдает главную страницу панели управления."""
index_path = os.path.join(STATIC_DIR, "index.html") index_path = os.path.join(STATIC_DIR, "index.html")
if os.path.exists(index_path): if os.path.exists(index_path):
return FileResponse(index_path) return FileResponse(index_path)
@@ -142,175 +69,24 @@ async def favicon():
return FileResponse(file_path) return FileResponse(file_path)
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
# --- [SECTION 5: AUTHENTICATION & USER MANAGEMENT] --- # ANCHOR[AUTH_JWT]
@app.post("/api/v1/auth/login")
def login(req: AuthRequest):
username = req.username.strip().lower()
logging.info(f"===> Попытка входа для пользователя: {username}")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,))
user = cursor.fetchone()
conn.close()
if not user or not pwd_context.verify(req.password, user["password_hash"]):
logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}")
raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль")
is_admin = bool(user["is_admin"]) or (user["username"] == "puh")
token = create_access_token(user["id"], user["username"], is_admin)
logging.info(f"===> УСПЕХ: Авторизован пользователь {username}")
return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin}
@app.post("/api/v1/auth/change-password")
def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
if not req.new_password or len(req.new_password) < 4:
raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],))
user = cursor.fetchone()
if not user or not pwd_context.verify(req.old_password, user["password_hash"]):
conn.close()
raise HTTPException(status_code=400, detail="Неверный старый пароль")
new_hash = pwd_context.hash(req.new_password)
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"]))
conn.commit()
conn.close()
logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}")
return {"status": "success", "message": "Пароль успешно изменен"}
@app.get("/api/v1/admin/users")
def list_users(current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC")
users = [dict(r) for r in cursor.fetchall()]
conn.close()
return users
@app.post("/api/v1/admin/users")
def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
username = req.username.strip().lower()
if not username or not req.password:
raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id FROM users WHERE username = ?", (username,))
if cursor.fetchone():
conn.close()
raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует")
pwd_hash = pwd_context.hash(req.password)
full_name = req.full_name.strip() if req.full_name else None
is_admin = 1 if req.is_admin else 0
cursor.execute(
"INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)",
(username, pwd_hash, full_name, is_admin)
)
conn.commit()
conn.close()
logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}")
return {"status": "success", "message": f"Пользователь {username} создан"}
@app.delete("/api/v1/admin/users/{user_id}")
def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
if user_id == current_user["id"]:
raise HTTPException(status_code=400, detail="Нельзя удалить самого себя")
conn = get_db()
cursor = conn.cursor()
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.commit()
conn.close()
logging.info(f"Удален пользователь ID: {user_id}")
return {"status": "success", "message": "Пользователь удален"}
# --- [SECTION 6: TASK TRACKER & LLM CHAT PIPELINE] --- # ANCHOR[CHAT_PIPELINE]
@app.get("/api/v1/tasks")
def get_tasks(user: Dict[str, Any] = Depends(get_current_user)):
"""Получить задачи текущего авторизованного пользователя."""
return db_get_tasks(user_id=user["id"])
# --- ЧАТ С АВТОРИЗАЦИЕЙ ---
@app.post("/api/v1/chat")
async def chat_endpoint(
session_id: str = Form("web_session_main"),
message: str = Form(""),
file: Optional[UploadFile] = File(default=None),
current_user: dict = Depends(get_current_user)
):
parsed_file = {"text": "", "image_b64": None}
if file and file.filename:
file_bytes = await file.read()
parsed_file = extract_text_from_file(file_bytes, file.filename)
reply, history, action_type = process_chat_message(
user_id=current_user["id"],
user_message=message,
file_context=parsed_file["text"],
image_b64=parsed_file["image_b64"],
session_id=session_id
)
return {"reply": reply, "history": history, "action_type": action_type}
# --- ГОСТЕВОЙ ЧАТ ---
@app.post("/api/v1/chat/guest")
async def guest_chat_endpoint(
session_id: str = Form("web_session_main"),
message: str = Form(""),
file: Optional[UploadFile] = File(default=None)
):
parsed_file = {"text": "", "image_b64": None}
if file and file.filename:
file_bytes = await file.read()
parsed_file = extract_text_from_file(file_bytes, file.filename)
reply, history, action_type = process_chat_message(
user_id=0,
user_message=message,
file_context=parsed_file["text"],
image_b64=parsed_file["image_b64"],
session_id=session_id
)
return {"reply": reply, "history": history, "action_type": action_type}
# --- [SECTION 7: STATIC FALLBACK ROUTER] --- # ANCHOR[STATIC_FALLBACK]
@app.get("/{file_path:path}") @app.get("/{file_path:path}")
def serve_static_fallback(file_path: str): def serve_static_fallback(file_path: str):
clean_path = file_path.lstrip("/") clean_path = file_path.lstrip("/")
target = os.path.join(STATIC_DIR, clean_path) target = os.path.join(STATIC_DIR, clean_path)
if os.path.isfile(target): if os.path.isfile(target):
if clean_path.endswith(".js"):
return FileResponse(target, media_type="application/javascript")
elif clean_path.endswith(".css"):
return FileResponse(target, media_type="text/css")
return FileResponse(target) return FileResponse(target)
# Резервный рекурсивный поиск файла в static
filename = os.path.basename(clean_path) filename = os.path.basename(clean_path)
target_js = os.path.join(STATIC_DIR, "js", filename) for root, _, files in os.walk(STATIC_DIR):
if filename.endswith(".js") and os.path.isfile(target_js): if filename in files:
return FileResponse(target_js, media_type="application/javascript") full_path = os.path.join(root, filename)
media = "application/javascript" if filename.endswith(".js") else "text/css"
target_css = os.path.join(STATIC_DIR, "css", filename) return FileResponse(full_path, media_type=media)
if filename.endswith(".css") and os.path.isfile(target_css):
return FileResponse(target_css, media_type="text/css")
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
+83
View File
@@ -0,0 +1,83 @@
"""
===============================================================================
FILE: modules/web_api/routers/admin.py
ROLE: Администрирование пользователей и прав доступа.
===============================================================================
"""
# ANCHOR[ADMIN_ROUTER_IMPORTS]
import logging
from typing import Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from .auth import get_current_user, get_db, pwd_context
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
# ANCHOR[ADMIN_SCHEMAS]
class CreateUserRequest(BaseModel):
username: str
password: str
full_name: Optional[str] = None
is_admin: Optional[bool] = False
# ANCHOR[ADMIN_ENDPOINTS]
@router.get("/users")
def list_users(current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC")
users = [dict(r) for r in cursor.fetchall()]
conn.close()
return users
@router.post("/users")
def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
username = req.username.strip().lower()
if not username or not req.password:
raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id FROM users WHERE username = ?", (username,))
if cursor.fetchone():
conn.close()
raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует")
pwd_hash = pwd_context.hash(req.password)
full_name = req.full_name.strip() if req.full_name else None
is_admin = 1 if req.is_admin else 0
cursor.execute(
"INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)",
(username, pwd_hash, full_name, is_admin)
)
conn.commit()
conn.close()
logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}")
return {"status": "success", "message": f"Пользователь {username} создан"}
@router.delete("/users/{user_id}")
def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
if user_id == current_user["id"]:
raise HTTPException(status_code=400, detail="Нельзя удалить самого себя")
conn = get_db()
cursor = conn.cursor()
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.commit()
conn.close()
logging.info(f"Удален пользователь ID: {user_id}")
return {"status": "success", "message": "Пользователь удален"}
+112
View File
@@ -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": "Пароль успешно изменен"}
+93
View File
@@ -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": "Черновик успешно обновлен в сессии"}
+71
View File
@@ -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
@@ -1,47 +0,0 @@
import os
import sqlite3
# Автопоиск файла базы данных в проекте
db_path = '/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db' if os.path.exists('/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db') else 'scud_orion_ai.db'
print("=" * 80)
print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}")
print("=" * 80)
if not os.path.exists(db_path):
print(f"❌ Файл базы данных {db_path} не найден!")
exit(1)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 1. Список всех таблиц и колонок
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
tables = [t[0] for t in cursor.fetchall()]
print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:")
print("-" * 80)
for t_name in tables:
cursor.execute(f"PRAGMA table_info({t_name})")
cols = [c[1] for c in cursor.fetchall()]
cursor.execute(f"SELECT COUNT(*) FROM {t_name}")
count = cursor.fetchone()[0]
print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}")
# 2. Просмотр правил Базы Знаний
if 'ai_knowledge_base' in tables:
print("\n" + "=" * 80)
print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):")
print("=" * 80)
cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC")
rules = cursor.fetchall()
if not rules:
print("Таблица ai_knowledge_base пуста.")
else:
for r_id, r_text, r_author in rules:
print(f" {r_id}. [{r_author}] {r_text}\n")
conn.close()
print("=" * 80)
@@ -1,23 +0,0 @@
import os
print("=" * 80)
print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_orion_context)")
print("=" * 80)
total_files = 0
total_size = 0
for root, dirs, files in os.walk('.'):
# Исключаем служебные каталоги
dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']]
for f in files:
p = os.path.join(root, f)
size = os.path.getsize(p)
total_files += 1
total_size += size
print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' '))
print("-" * 80)
print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB")
print("=" * 80)
@@ -1,31 +0,0 @@
import os
OUTPUT_SNAPSHOT = "api_code_snapshot.md"
# Расширения файлов для включения в снимок
ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini', '.js', '.html', '.css'}
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_context_api.tar.gz', 'context_memory.db'}
print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...")
with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out:
out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_context_api\n\n")
for root, dirs, files in os.walk('.'):
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
for file in sorted(files):
ext = os.path.splitext(file)[1].lower()
if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES:
filepath = os.path.join(root, file)
out.write(f"## File: `{filepath}`\n")
out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n")
try:
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
out.write(f.read())
except Exception as e:
out.write(f"// Ошибка чтения файла: {e}\n")
out.write("\n```\n\n")
print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)")
+18 -15
View File
@@ -10,7 +10,7 @@
</head> </head>
<body class="bg-slate-100 text-slate-800 h-[100dvh] w-full flex flex-col font-sans overflow-hidden"> <body class="bg-slate-100 text-slate-800 h-[100dvh] w-full flex flex-col font-sans overflow-hidden">
<!-- Окно авторизации --> <!-- ANCHOR[AUTH_MODAL]: Модальное окно входа -->
<div id="auth-modal" class="fixed inset-0 bg-slate-900/80 backdrop-blur-md z-50 flex items-center justify-center p-4"> <div id="auth-modal" class="fixed inset-0 bg-slate-900/80 backdrop-blur-md z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl p-6 sm:p-8 max-w-md w-full shadow-2xl border border-slate-200"> <div class="bg-white rounded-2xl p-6 sm:p-8 max-w-md w-full shadow-2xl border border-slate-200">
<div class="flex items-center space-x-3 mb-6"> <div class="flex items-center space-x-3 mb-6">
@@ -56,7 +56,7 @@
</div> </div>
</div> </div>
<!-- Модальное окно смены пароля --> <!-- ANCHOR[CHANGE_PWD_MODAL]: Модальное окно смены пароля -->
<div id="change-pwd-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4"> <div id="change-pwd-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
<div class="bg-white rounded-2xl p-6 max-w-sm w-full shadow-2xl border border-slate-200"> <div class="bg-white rounded-2xl p-6 max-w-sm w-full shadow-2xl border border-slate-200">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
@@ -92,7 +92,7 @@
</div> </div>
</div> </div>
<!-- Модальное окно управления пользователями --> <!-- ANCHOR[ADMIN_MODAL]: Модальное окно управления пользователями -->
<div id="admin-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4"> <div id="admin-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
<div class="bg-white rounded-2xl p-6 max-w-lg w-full shadow-2xl border border-slate-200 flex flex-col max-h-[85vh]"> <div class="bg-white rounded-2xl p-6 max-w-lg w-full shadow-2xl border border-slate-200 flex flex-col max-h-[85vh]">
<div class="flex justify-between items-center mb-4 pb-2 border-b border-slate-200"> <div class="flex justify-between items-center mb-4 pb-2 border-b border-slate-200">
@@ -129,7 +129,7 @@
</div> </div>
</div> </div>
<!-- Хедер --> <!-- ANCHOR[HEADER_BAR]: Хедер интерфейса -->
<header class="bg-white border-b border-slate-200 px-4 py-2.5 flex justify-between items-center shadow-sm shrink-0 z-20"> <header class="bg-white border-b border-slate-200 px-4 py-2.5 flex justify-between items-center shadow-sm shrink-0 z-20">
<div class="flex items-center space-x-2.5"> <div class="flex items-center space-x-2.5">
<div class="bg-indigo-600 text-white p-2 rounded-xl shrink-0"> <div class="bg-indigo-600 text-white p-2 rounded-xl shrink-0">
@@ -170,16 +170,16 @@
</div> </div>
</header> </header>
<!-- Главный контейнер --> <!-- ANCHOR[CHAT_MAIN_CONTAINER]: Главный контейнер чата -->
<div class="flex-1 flex flex-col min-h-0 w-full max-w-4xl mx-auto bg-white relative overflow-hidden"> <div class="flex-1 flex flex-col min-h-0 w-full max-w-4xl mx-auto bg-white relative overflow-hidden">
<div id="chat-window" class="flex-1 p-3.5 overflow-y-auto space-y-3 bg-slate-50/50"> <div id="chat-window" class="flex-1 p-3.5 overflow-y-auto space-y-3 bg-slate-50/50">
<div id="drop-overlay" class="absolute inset-0 bg-indigo-600/10 backdrop-blur-sm border-2 border-dashed border-indigo-600 rounded-2xl hidden flex-col items-center justify-center z-30 transition-all pointer-events-none"> <div id="drop-overlay" class="absolute inset-0 bg-indigo-600/10 backdrop-blur-sm border-2 border-dashed border-indigo-600 rounded-2xl hidden flex-col items-center justify-center z-30 transition-all pointer-events-none">
<div class="bg-white p-4 rounded-2xl shadow-xl flex flex-col items-center gap-2"> <div class="bg-white p-4 rounded-2xl shadow-xl flex flex-col items-center gap-2">
<i class="fa-solid fa-cloud-arrow-up text-3xl text-indigo-600 animate-bounce"></i> <i class="fa-solid fa-cloud-arrow-up text-3xl text-indigo-600 animate-bounce"></i>
<p class="text-sm font-bold text-slate-800">Перетащите файл сюда</p> <p class="text-sm font-bold text-slate-800">Перетащите файл сюда</p>
<p class="text-xs text-slate-500">Поддерживаются PDF, изображения, таблицы, TXT</p> <p class="text-xs text-slate-500">Поддерживаются PDF, изображения, таблицы, TXT</p>
</div>
</div> </div>
</div>
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm"> <div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm">
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1"> <p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
<i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент <i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент
@@ -202,9 +202,9 @@
</button> </button>
</div> </div>
<!-- Поле ввода -->
<div class="p-2.5 pb-6 bg-white border-t border-slate-200 shrink-0 z-10 shadow-lg"> <div class="p-2.5 pb-6 bg-white border-t border-slate-200 shrink-0 z-10 shadow-lg">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- Скрытый инпут и кнопка прикрепления файла -->
<input type="file" id="file-input" class="hidden" onchange="handleFileSelect(event)" accept=".png,.jpg,.jpeg,.pdf,.txt,.csv,.xlsx"> <input type="file" id="file-input" class="hidden" onchange="handleFileSelect(event)" accept=".png,.jpg,.jpeg,.pdf,.txt,.csv,.xlsx">
<button type="button" onclick="document.getElementById('file-input').click()" class="text-slate-500 hover:text-indigo-600 p-2 rounded-xl transition" title="Прикрепить файл"> <button type="button" onclick="document.getElementById('file-input').click()" class="text-slate-500 hover:text-indigo-600 p-2 rounded-xl transition" title="Прикрепить файл">
<i class="fa-solid fa-paperclip text-lg"></i> <i class="fa-solid fa-paperclip text-lg"></i>
@@ -223,7 +223,7 @@
</div> </div>
</div> </div>
<!-- Выезжающая панель (Drawer) --> <!-- ANCHOR[TASK_DRAWER_CONTAINER]: Боковая панель задач -->
<div id="drawer-backdrop" onclick="toggleDrawer()" class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm hidden transition-opacity z-40"></div> <div id="drawer-backdrop" onclick="toggleDrawer()" class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm hidden transition-opacity z-40"></div>
<aside id="task-drawer" class="fixed right-0 top-0 h-full w-full sm:w-[420px] bg-white border-l border-slate-200 shadow-2xl transform translate-x-full transition-transform duration-300 ease-in-out z-50 flex flex-col"> <aside id="task-drawer" class="fixed right-0 top-0 h-full w-full sm:w-[420px] bg-white border-l border-slate-200 shadow-2xl transform translate-x-full transition-transform duration-300 ease-in-out z-50 flex flex-col">
@@ -241,11 +241,12 @@
</div> </div>
</div> </div>
<!-- ANCHOR[DRAWER_TAB_RENAMING]: Вкладки фильтрации боковой панели -->
<div class="flex border-b border-slate-200 bg-white px-2 pt-2 text-xs font-semibold text-slate-500 gap-1 overflow-x-auto no-scrollbar shrink-0"> <div class="flex border-b border-slate-200 bg-white px-2 pt-2 text-xs font-semibold text-slate-500 gap-1 overflow-x-auto no-scrollbar shrink-0">
<button type="button" onclick="setFilter('ALL')" id="filter-ALL" class="px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap">Все</button> <button type="button" onclick="setFilter('ALL')" id="filter-ALL" class="px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap">Все</button>
<button type="button" onclick="setFilter('IN_PROGRESS')" id="filter-IN_PROGRESS" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">В работе</button> <button type="button" onclick="setFilter('IN_PROGRESS')" id="filter-IN_PROGRESS" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">В работе</button>
<button type="button" onclick="setFilter('BACKLOG')" id="filter-BACKLOG" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">Бэклог</button> <button type="button" onclick="setFilter('BACKLOG')" id="filter-BACKLOG" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">В планах</button>
<button type="button" onclick="setFilter('COMPLETED')" id="filter-COMPLETED" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">Завершено</button> <button type="button" onclick="setFilter('COMPLETED')" id="filter-COMPLETED" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">Завершенные</button>
</div> </div>
<div id="tasks-container" class="flex-1 overflow-y-auto p-3.5 space-y-3 bg-slate-50/50 pb-8"> <div id="tasks-container" class="flex-1 overflow-y-auto p-3.5 space-y-3 bg-slate-50/50 pb-8">
@@ -253,9 +254,11 @@
</div> </div>
</aside> </aside>
<!-- Подключение скриптов интерфейса -->
<script src="/static/js/auth.js"></script> <script src="/static/js/auth.js"></script>
<script src="/static/js/tasks.js"></script> <script src="/static/js/tasks.js"></script>
<script src="/static/js/chat.js"></script> <script src="/static/js/chat/task_widget.js"></script>
<script src="/static/js/chat/core.js"></script>
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>
</body> </body>
</html> </html>
@@ -1,4 +1,13 @@
// Вспомогательная функция для автоматического изменения высоты текстового поля /**
===============================================================================
FILE: modules/web_api/static/js/chat/core.js
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / static / js / chat
ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, блокировка ввода
при активных кнопках, динамический вызов инлайн-редактора и двусторонний Diff.
===============================================================================
*/
function updateInputHeight(el) { function updateInputHeight(el) {
if (!el) return; if (!el) return;
el.style.height = "24px"; el.style.height = "24px";
@@ -36,7 +45,29 @@ function clearAttachedFile() {
if (previewContainer) previewContainer.classList.add("hidden"); 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() { function disableAllActionButtons() {
const allBtnContainers = document.querySelectorAll(".action-buttons-container"); const allBtnContainers = document.querySelectorAll(".action-buttons-container");
allBtnContainers.forEach(container => { allBtnContainers.forEach(container => {
@@ -47,9 +78,9 @@ function disableAllActionButtons() {
}); });
} }
// Быстрая отправка текста кнопки
function handleActionButtonClick(text) { function handleActionButtonClick(text) {
disableAllActionButtons(); disableAllActionButtons();
setInputLocked(false);
const input = document.getElementById("user-input"); const input = document.getElementById("user-input");
if (input) { if (input) {
input.value = text; input.value = text;
@@ -57,6 +88,157 @@ function handleActionButtonClick(text) {
} }
} }
function escapeHtml(text) {
if (!text) return "";
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// --- [INLINE PROMPT EDITOR & FULL DIFF BUILDER] ---
function openInlinePromptEditor(btnEl) {
const cardContainer = btnEl.closest(".chat-message-card");
if (!cardContainer) return;
const editorContainer = cardContainer.querySelector(".inline-prompt-editor-container");
const diffView = cardContainer.querySelector(".prompt-preview-diff-view");
const textarea = cardContainer.querySelector(".inline-prompt-textarea");
if (editorContainer && textarea) {
editorContainer.classList.remove("hidden");
if (diffView) {
const targetHeight = Math.max(diffView.offsetHeight, 320);
textarea.style.height = `${targetHeight}px`;
}
textarea.focus();
}
}
function closeInlinePromptEditor(btnEl) {
const cardContainer = btnEl.closest(".chat-message-card");
if (!cardContainer) return;
const editorContainer = cardContainer.querySelector(".inline-prompt-editor-container");
if (editorContainer) editorContainer.classList.add("hidden");
}
function parsePromptIntoMap(text) {
const map = new Map();
if (!text) return map;
const lines = text.split("\n");
for (let line of lines) {
let trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^(\d+)[\.\s]+(\d+)[\.\s\:\-]*(.*)$/);
if (match) {
const key = `${match[1]}.${match[2]}`;
map.set(key, { sec: parseInt(match[1]), itm: parseInt(match[2]), content: match[3].trim(), full: trimmed });
}
}
return map;
}
function buildHighlightedPromptHtml(newRawText, originalBaselineText) {
const origMap = parsePromptIntoMap(originalBaselineText);
const newMap = parsePromptIntoMap(newRawText);
const formattedLines = [];
const rawLines = newRawText.split("\n");
const handledNewKeys = new Set();
let currentSection = null;
for (let line of rawLines) {
let trimmed = line.trim();
if (!trimmed) {
formattedLines.push("");
continue;
}
const secMatch = trimmed.match(/^(\d+)[\.\s\:\-]+([^\d].*)$/);
const subMatch = trimmed.match(/^(\d+)[\.\s]+(\d+)[\.\s\:\-]*(.*)$/);
if (subMatch) {
const key = `${subMatch[1]}.${subMatch[2]}`;
handledNewKeys.add(key);
currentSection = parseInt(subMatch[1]);
const cleanContent = subMatch[3].trim();
const fullItemStr = `${subMatch[1]}.${subMatch[2]}. ${cleanContent}`;
if (!origMap.has(key) || origMap.get(key).content !== cleanContent) {
// Добавленный или изменённый пункт
formattedLines.push(` <span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">${escapeHtml(fullItemStr)}</span>`);
} else {
formattedLines.push(` ${escapeHtml(fullItemStr)}`);
}
} else if (secMatch && !anyLower(secMatch[2].slice(0, 15))) {
// Заголовок раздела
currentSection = parseInt(secMatch[1]);
formattedLines.push(escapeHtml(trimmed));
} else {
formattedLines.push(escapeHtml(trimmed));
}
}
// Добавляем удаленные пункты (были в оригинале, но отсутствуют в новом тексте)
for (let [origKey, origObj] of origMap.entries()) {
if (!handledNewKeys.has(origKey)) {
const strikeMarkup = ` <span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">${origKey}. ${escapeHtml(origObj.content)} [УДАЛЕНИЕ]</span>`;
formattedLines.push(strikeMarkup);
}
}
return formattedLines.join("\n");
}
function anyLower(str) {
return /[а-яa-z]/.test(str);
}
async function saveManualPromptDraft(btnEl) {
const cardContainer = btnEl.closest(".chat-message-card");
if (!cardContainer) return;
const textarea = cardContainer.querySelector(".inline-prompt-textarea");
const previewTextEl = cardContainer.querySelector(".prompt-preview-diff-view");
if (!textarea) return;
const newText = textarea.value.trim();
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(btnEl);
if (previewTextEl) {
const baseline = cardContainer.dataset.originalBaseline || "";
const highlightedHtml = buildHighlightedPromptHtml(newText, baseline);
previewTextEl.innerHTML = highlightedHtml;
}
}
} catch (err) {
alert("Ошибка сохранения черновика: " + err);
}
}
// --- [CHAT SEND PIPELINE] ---
async function sendMessage(e) { async function sendMessage(e) {
if (e && e.preventDefault) e.preventDefault(); if (e && e.preventDefault) e.preventDefault();
@@ -69,7 +251,6 @@ async function sendMessage(e) {
if (!text && !selectedFile) return; if (!text && !selectedFile) return;
// Деактивируем предыдущие интерактивные кнопки
disableAllActionButtons(); disableAllActionButtons();
let userDisplayHtml = escapeHtml(text); let userDisplayHtml = escapeHtml(text);
@@ -131,7 +312,6 @@ async function sendMessage(e) {
const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI"; const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI";
const replyText = data.reply || "Пустой ответ от нейросети"; const replyText = data.reply || "Пустой ответ от нейросети";
// Генерация блока кнопок подтверждения при необходимости
let actionButtonsHtml = ""; let actionButtonsHtml = "";
const actionData = data.action_type || data.action; const actionData = data.action_type || data.action;
@@ -143,6 +323,7 @@ async function sendMessage(e) {
const labelLower = (btn.label || "").toLowerCase(); const labelLower = (btn.label || "").toLowerCase();
const isPrimary = btn.style === "primary" || labelLower.includes("подтверд") || labelLower.startsWith("да"); const isPrimary = btn.style === "primary" || labelLower.includes("подтверд") || labelLower.startsWith("да");
const isDanger = btn.style === "danger" || labelLower.includes("отмен") || labelLower.startsWith("нет") || labelLower.includes("законч") || labelLower.includes("заверш"); const isDanger = btn.style === "danger" || labelLower.includes("отмен") || labelLower.startsWith("нет") || labelLower.includes("законч") || labelLower.includes("заверш");
const isEdit = labelLower.includes("редактир");
if (isPrimary) { if (isPrimary) {
btnClasses = "bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm"; btnClasses = "bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm";
@@ -150,10 +331,15 @@ async function sendMessage(e) {
} else if (isDanger) { } else if (isDanger) {
btnClasses = "bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300"; btnClasses = "bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300";
iconMarkup = '<i class="fa-solid fa-xmark"></i>'; iconMarkup = '<i class="fa-solid fa-xmark"></i>';
} else if (isEdit) {
btnClasses = "bg-indigo-50 hover:bg-indigo-100 active:bg-indigo-200 text-indigo-700 border border-indigo-300";
iconMarkup = '<i class="fa-solid fa-pen-to-square text-xs"></i>';
} }
const clickHandler = isEdit ? "openInlinePromptEditor(this)" : `handleActionButtonClick('${escapeHtml(btn.value)}')`;
return ` return `
<button type="button" onclick="handleActionButtonClick('${escapeHtml(btn.value)}')" <button type="button" onclick="${clickHandler}"
class="${btnClasses} font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer"> class="${btnClasses} font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
${iconMarkup} ${iconMarkup}
<span>${escapeHtml(btn.label)}</span> <span>${escapeHtml(btn.label)}</span>
@@ -166,14 +352,44 @@ async function sendMessage(e) {
${buttonsMarkup} ${buttonsMarkup}
</div> </div>
`; `;
setInputLocked(true);
} else {
setInputLocked(false);
}
let previewEditorHtml = "";
let rawDraftText = "";
let baselineText = "";
if (actionData && actionData.type === "PROMPT_PREVIEW") {
rawDraftText = actionData.raw_draft || "";
baselineText = actionData.baseline_prompt || rawDraftText;
previewEditorHtml = `
<div class="inline-prompt-editor-container hidden mt-3 p-3 bg-slate-50 border border-slate-300 rounded-xl space-y-2">
<p class="text-[11px] font-bold text-slate-700 uppercase"><i class="fa-solid fa-pen-to-square mr-1"></i> Ручное редактирование текста промпта:</p>
<textarea class="inline-prompt-textarea w-full bg-white border border-slate-300 rounded-lg p-3 text-xs font-mono text-slate-800 focus:outline-none focus:border-indigo-600 resize-y shadow-inner leading-relaxed">${escapeHtml(rawDraftText)}</textarea>
<div class="flex justify-end gap-2 pt-1">
<button type="button" onclick="closeInlinePromptEditor(this)" class="px-3 py-1.5 rounded-lg text-xs text-slate-600 bg-slate-200 hover:bg-slate-300 transition">Свернуть</button>
<button type="button" onclick="saveManualPromptDraft(this)" class="px-3.5 py-1.5 rounded-lg text-xs font-semibold text-white bg-indigo-600 hover:bg-indigo-700 shadow-sm transition">Сохранить правки</button>
</div>
</div>
`;
}
let interactiveWidgetHtml = "";
if (actionData && actionData.type === "TASK_INTERACTIVE_CARD" && typeof renderInteractiveTaskCard === "function") {
interactiveWidgetHtml = renderInteractiveTaskCard(actionData.tasks);
} }
const botMsgHtml = ` const botMsgHtml = `
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm max-w-2xl mb-3"> <div class="chat-message-card bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm max-w-2xl mb-3" data-original-baseline="${escapeHtml(baselineText)}">
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1"> <p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
<i class="fa-solid fa-robot mr-1"></i> ${assistantTitle} <i class="fa-solid fa-robot mr-1"></i> ${assistantTitle}
</p> </p>
<p class="text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${escapeHtml(replyText)}</p> <div class="prompt-preview-diff-view text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${replyText}</div>
${previewEditorHtml}
${interactiveWidgetHtml}
${actionButtonsHtml} ${actionButtonsHtml}
</div> </div>
`; `;
@@ -195,24 +411,17 @@ async function sendMessage(e) {
`; `;
chatWindow.insertAdjacentHTML("beforeend", errorHtml); chatWindow.insertAdjacentHTML("beforeend", errorHtml);
chatWindow.scrollTop = chatWindow.scrollHeight; chatWindow.scrollTop = chatWindow.scrollHeight;
setInputLocked(false);
} finally { } finally {
if (sendBtn) { const inputEl = document.getElementById("user-input");
if (sendBtn && (!inputEl || !inputEl.disabled)) {
sendBtn.disabled = false; sendBtn.disabled = false;
sendBtn.classList.remove("opacity-50"); sendBtn.classList.remove("opacity-50");
} }
} }
} }
function escapeHtml(text) { // --- [DRAG & DROP AND KEYBOARD LISTENERS] ---
if (!text) return "";
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const input = document.getElementById("user-input"); const input = document.getElementById("user-input");
const dropZone = document.getElementById("chat-window")?.parentElement; const dropZone = document.getElementById("chat-window")?.parentElement;
@@ -225,6 +434,7 @@ document.addEventListener("DOMContentLoaded", () => {
input.addEventListener("keydown", (e) => { input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
if (input.disabled) return;
const text = input.value.trim(); const text = input.value.trim();
if (text) { if (text) {
if (localHistory.length === 0 || localHistory[0] !== text) { if (localHistory.length === 0 || localHistory[0] !== text) {
@@ -0,0 +1,208 @@
/**
===============================================================================
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 `<div class="text-slate-400 text-xs py-4 text-center">Нет задач со статусом «${label}»</div>`;
}
return filtered.map(t => {
const isDone = t.status === "COMPLETED";
const isProgress = t.status === "IN_PROGRESS";
const checkedAttr = isDone ? "checked" : "";
const textClass = isDone ? "line-through text-slate-400" : "text-slate-800 font-medium";
let statusPill = `<span class="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded border border-slate-200">В планах</span>`;
if (isDone) {
statusPill = `<span class="text-[10px] bg-emerald-50 text-emerald-700 px-2 py-0.5 rounded border border-emerald-300 font-semibold">Завершено</span>`;
} else if (isProgress) {
statusPill = `<span class="text-[10px] bg-amber-50 text-amber-700 px-2 py-0.5 rounded border border-amber-300 font-semibold">В работе</span>`;
}
let prioPill = "";
if (t.priority === "HIGH") {
prioPill = `<span class="text-[9px] bg-red-50 text-red-700 font-bold px-1.5 py-0.5 rounded border border-red-200 uppercase">HIGH</span>`;
}
return `
<div class="flex items-center justify-between p-2.5 bg-slate-50 hover:bg-slate-100/80 rounded-xl border border-slate-200 transition gap-2" id="widget-task-${t.task_id}">
<div class="flex items-center gap-2.5 flex-1 min-w-0">
<input type="checkbox" ${checkedAttr} onchange="toggleTaskStatusInline('${t.task_id}', this.checked)"
class="w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500 cursor-pointer">
<div class="flex flex-col min-w-0 flex-1">
<div class="flex items-center gap-1.5 mb-0.5">
<span class="font-mono text-[11px] font-bold text-slate-700">${t.task_id}</span>
${prioPill}
${statusPill}
</div>
<span class="text-xs ${textClass} truncate leading-tight">${escapeHtml(t.title)}</span>
</div>
</div>
<button type="button" onclick="deleteTaskInline('${t.task_id}')" class="text-slate-400 hover:text-red-500 p-1.5 transition rounded-lg hover:bg-red-50" title="Удалить задачу">
<i class="fa-solid fa-trash-can text-xs"></i>
</button>
</div>
`;
}).join("");
}
function renderInteractiveTaskCard(tasks) {
if (!tasks || !Array.isArray(tasks)) return "";
const widgetId = "task_widget_" + Date.now();
activeWidgetTasksMap.set(widgetId, { tasks: tasks, filter: "ALL" });
const itemsHtml = renderTaskItemsHtml(tasks, "ALL");
return `
<div class="task-widget-card mt-3 bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm space-y-3" id="${widgetId}">
<div class="flex items-center gap-1 pb-2 border-b border-slate-100 overflow-x-auto no-scrollbar text-xs font-semibold">
<button type="button" onclick="filterTaskWidget('${widgetId}', 'ALL', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-600 font-bold border border-indigo-200">Все</button>
<button type="button" onclick="filterTaskWidget('${widgetId}', 'IN_PROGRESS', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700">В работе</button>
<button type="button" onclick="filterTaskWidget('${widgetId}', 'BACKLOG', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700">В планах</button>
<button type="button" onclick="filterTaskWidget('${widgetId}', 'COMPLETED', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700">Завершенные</button>
</div>
<div class="widget-items-container space-y-2 max-h-72 overflow-y-auto pr-1">
${itemsHtml}
</div>
<div class="pt-2 border-t border-slate-100 flex items-center gap-2">
<input type="text" placeholder="Новая задача..." class="flex-1 bg-slate-50 border border-slate-200 rounded-xl px-3 py-1.5 text-xs text-slate-800 focus:outline-none focus:border-indigo-600 focus:bg-white transition" onkeydown="if(event.key==='Enter') addTaskInline('${widgetId}', this)">
<button type="button" onclick="addTaskInline('${widgetId}', this.previousElementSibling)" class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white px-3 py-1.5 rounded-xl text-xs font-semibold shadow-sm transition flex items-center gap-1">
<i class="fa-solid fa-plus text-[10px]"></i> Добавить
</button>
</div>
</div>
`;
}
function filterTaskWidget(widgetId, status, btnEl) {
const state = activeWidgetTasksMap.get(widgetId);
if (!state) return;
state.filter = status;
const widgetEl = document.getElementById(widgetId);
if (!widgetEl) return;
widgetEl.querySelectorAll(".widget-tab-btn").forEach(b => {
b.className = "widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700";
});
btnEl.className = "widget-tab-btn px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-600 font-bold border border-indigo-200";
const container = widgetEl.querySelector(".widget-items-container");
if (container) {
container.innerHTML = renderTaskItemsHtml(state.tasks, status);
}
}
async function toggleTaskStatusInline(taskId, isChecked) {
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const newStatus = isChecked ? "COMPLETED" : "BACKLOG";
try {
const res = await fetch(`/api/v1/tasks/${taskId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify({ status: newStatus })
});
if (res.ok) {
for (let [wId, state] of activeWidgetTasksMap.entries()) {
const targetTask = state.tasks.find(t => t.task_id === taskId);
if (targetTask) {
targetTask.status = newStatus;
const widgetEl = document.getElementById(wId);
const container = widgetEl?.querySelector(".widget-items-container");
if (container) {
container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter);
}
}
}
if (typeof loadTasks === "function") loadTasks();
}
} catch (err) {
console.error("[Task Patch Error]", err);
}
}
async function deleteTaskInline(taskId) {
if (!confirm(`Удалить задачу ${taskId}?`)) return;
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
try {
const res = await fetch(`/api/v1/tasks/${taskId}`, {
method: "DELETE",
headers: { "Authorization": "Bearer " + token }
});
if (res.ok) {
for (let [wId, state] of activeWidgetTasksMap.entries()) {
state.tasks = state.tasks.filter(t => t.task_id !== taskId);
const widgetEl = document.getElementById(wId);
const container = widgetEl?.querySelector(".widget-items-container");
if (container) {
container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter);
}
}
if (typeof loadTasks === "function") loadTasks();
}
} catch (err) {
console.error("[Task Delete Error]", err);
}
}
async function addTaskInline(widgetId, inputEl) {
if (!inputEl) return;
const title = inputEl.value.trim();
if (!title) return;
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
try {
const res = await fetch("/api/v1/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify({ title: title, priority: "MEDIUM", module: "general" })
});
if (res.ok) {
inputEl.value = "";
const tasksRes = await fetch("/api/v1/tasks", {
headers: { "Authorization": "Bearer " + token }
});
const updatedTasks = await tasksRes.json();
const state = activeWidgetTasksMap.get(widgetId);
if (state) {
state.tasks = updatedTasks;
const widgetEl = document.getElementById(widgetId);
const container = widgetEl?.querySelector(".widget-items-container");
if (container) {
container.innerHTML = renderTaskItemsHtml(updatedTasks, state.filter);
}
}
if (typeof loadTasks === "function") loadTasks();
}
} catch (err) {
console.error("[Task Add Error]", err);
}
}
+22 -3
View File
@@ -1,6 +1,20 @@
/**
===============================================================================
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 currentFilter = 'ALL';
let allTasks = []; let allTasks = [];
// --- [SECTION 1: DRAWER TOGGLE] --- # ANCHOR[DRAWER_TOGGLE]
function toggleDrawer() { function toggleDrawer() {
if (typeof IS_GUEST !== 'undefined' && IS_GUEST) return; if (typeof IS_GUEST !== 'undefined' && IS_GUEST) return;
const drawer = document.getElementById("task-drawer"); const drawer = document.getElementById("task-drawer");
@@ -18,6 +32,7 @@ function toggleDrawer() {
} }
} }
// --- [SECTION 2: FILTER CONTROL] --- # ANCHOR[TASKS_FILTER]
function setFilter(status) { function setFilter(status) {
currentFilter = status; currentFilter = status;
["ALL", "IN_PROGRESS", "BACKLOG", "COMPLETED"].forEach(f => { ["ALL", "IN_PROGRESS", "BACKLOG", "COMPLETED"].forEach(f => {
@@ -31,6 +46,7 @@ function setFilter(status) {
renderTasks(); renderTasks();
} }
// --- [SECTION 3: ASYNC DATA FETCH] --- # ANCHOR[TASKS_LOAD_FETCH]
async function loadTasks() { async function loadTasks() {
const badge = document.getElementById("task-count-badge"); const badge = document.getElementById("task-count-badge");
const container = document.getElementById("tasks-container"); const container = document.getElementById("tasks-container");
@@ -62,7 +78,6 @@ async function loadTasks() {
const data = await res.json(); const data = await res.json();
// Гибкое определение структуры данных (массив или объект с ключом tasks)
if (Array.isArray(data)) { if (Array.isArray(data)) {
allTasks = data; allTasks = data;
} else if (data && Array.isArray(data.tasks)) { } else if (data && Array.isArray(data.tasks)) {
@@ -88,6 +103,7 @@ async function loadTasks() {
} }
} }
// --- [SECTION 4: DOM RENDERING] --- # ANCHOR[TASKS_RENDER_DOM]
function renderTasks() { function renderTasks() {
const container = document.getElementById("tasks-container"); const container = document.getElementById("tasks-container");
if (!container) return; if (!container) return;
@@ -105,13 +121,16 @@ function renderTasks() {
container.innerHTML = filtered.map(t => { container.innerHTML = filtered.map(t => {
let statusBadge = "bg-slate-100 text-slate-600 border-slate-200"; let statusBadge = "bg-slate-100 text-slate-600 border-slate-200";
let statusLabel = "В планах";
let cardBg = "bg-white"; let cardBg = "bg-white";
if (t.status === "COMPLETED") { if (t.status === "COMPLETED") {
statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold"; statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold";
statusLabel = "Завершено";
cardBg = "bg-emerald-50/20"; cardBg = "bg-emerald-50/20";
} else if (t.status === "IN_PROGRESS") { } else if (t.status === "IN_PROGRESS") {
statusBadge = "bg-amber-50 text-amber-700 border-amber-300 font-bold"; statusBadge = "bg-amber-50 text-amber-700 border-amber-300 font-bold";
statusLabel = "В работе";
cardBg = "bg-amber-50/20 border-amber-200"; cardBg = "bg-amber-50/20 border-amber-200";
} }
@@ -131,7 +150,7 @@ function renderTasks() {
<span class="font-mono text-xs font-bold text-slate-900 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">${t.task_id || t.id || 'TASK'}</span> <span class="font-mono text-xs font-bold text-slate-900 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">${t.task_id || t.id || 'TASK'}</span>
<span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'MEDIUM'}</span> <span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'MEDIUM'}</span>
</div> </div>
<span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${t.status || 'BACKLOG'}</span> <span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${statusLabel}</span>
</div> </div>
<h3 class="text-xs font-semibold text-slate-800 mb-1 leading-snug">${t.title || t.description || ''}</h3> <h3 class="text-xs font-semibold text-slate-800 mb-1 leading-snug">${t.title || t.description || ''}</h3>
<div class="text-[10px] text-slate-400 font-mono flex items-center gap-1"> <div class="text-[10px] text-slate-400 font-mono flex items-center gap-1">
@@ -142,4 +161,4 @@ function renderTasks() {
</div> </div>
`; `;
}).join(""); }).join("");
} }
+3229 -992
View File
File diff suppressed because it is too large Load Diff
+128 -21
View File
@@ -51,11 +51,14 @@ def print_stats():
print("=" * 60) print("=" * 60)
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base'] tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks']
for t in tables: for t in tables:
cursor.execute(f"SELECT COUNT(*) FROM {t}") try:
cnt = cursor.fetchone()[0] cursor.execute(f"SELECT COUNT(*) FROM {t}")
print(f" • Таблица [{t:<18}]: {cnt:>6} записей") cnt = cursor.fetchone()[0]
print(f" • Таблица [{t:<20}]: {cnt:>6} записей")
except Exception:
pass
print("=" * 60 + "\n") 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) df = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_id)
if df.empty: if df.empty:
print(f"Записи СКУД не найдены.") print("Записи СКУД не найдены.")
print("=" * 90 + "\n") print("=" * 90 + "\n")
return 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) out_path = os.path.join(OUTPUT_DIR, out_filename)
print(f"\n[🔄] Создание полного дампа БД в файл: {out_path} ...") print(f"\n[🔄] Создание полного дампа БД в файл: {out_path} ...")
with get_connection() as conn, pd.ExcelWriter(out_path, engine='openpyxl') as writer: with get_connection() as conn, pd.ExcelWriter(out_path, engine='openpyxl') as writer:
for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base']: for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks']:
df = pd.read_sql_query(f"SELECT * FROM {table}", conn) try:
df.to_excel(writer, sheet_name=table[:31], index=False) df = pd.read_sql_query(f"SELECT * FROM {table}", conn)
df.to_excel(writer, sheet_name=table[:31], index=False)
except Exception:
pass
print(f"[✓] Дамп успешно сохранен: {out_path}\n") print(f"[✓] Дамп успешно сохранен: {out_path}\n")
def print_system_prompts(): def print_system_prompts():
"""Выводит все системные промпты из базы данных.""" """Выводит все системные промпты из базы данных."""
print("\n" + "=" * 80) print("\n" + "=" * 80)
print("📝 СИСТЕМНЫЕ ПРОМПТЫ (system_prompts):") print("📝 СИСТЕМНЫЕ ПРОМПТЫ (system_prompt_nodes):")
print("=" * 80) print("=" * 80)
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT id, name, is_active, updated_at, prompt_text FROM system_prompts ORDER BY id DESC") try:
rows = cursor.fetchall() cursor.execute("SELECT section_id, item_id, content, is_active, updated_at FROM system_prompt_nodes ORDER BY section_id ASC, item_id ASC")
rows = cursor.fetchall()
except Exception:
rows = []
if not rows: if not rows:
print("Таблица system_prompts пуста.") print("Таблица system_prompt_nodes пуста.")
else: else:
for r in rows: for r in rows:
print(f"ID: {r[0]} | Name: {r[1]} | Active: {r[2]} | Updated: {r[3]}") print(f"Раздел {r[0]}.{r[1]} | Active: {r[3]} | Updated: {r[4]}")
print(f" {r[2]}")
print("-" * 80) print("-" * 80)
print(f"{r[4]}\n")
print("=" * 80 + "\n") print("=" * 80 + "\n")
@@ -280,6 +290,89 @@ def print_session_states():
print("=" * 80 + "\n") print("=" * 80 + "\n")
def print_chat_messages(session_id=None, limit=50):
"""Выводит таблицу истории сообщений чата (контекст)."""
print("\n" + "=" * 105)
print(f"💬 ИСТОРИЯ СООБЩЕНИЙ ЧАТА (chat_messages) {f'для сессии: {session_id}' if session_id else 'все сессии'}:")
print("=" * 105)
with get_connection() as conn:
cursor = conn.cursor()
if session_id:
cursor.execute("""
SELECT id, session_id, role, content, is_ephemeral, created_at
FROM chat_messages
WHERE session_id = ?
ORDER BY id ASC
LIMIT ?
""", (session_id, limit))
else:
cursor.execute("""
SELECT id, session_id, role, content, is_ephemeral, created_at
FROM chat_messages
ORDER BY id DESC
LIMIT ?
""", (limit,))
rows = cursor.fetchall()
if not rows:
print("Таблица chat_messages пуста.")
else:
if not session_id:
rows = list(reversed(rows))
for r in rows:
msg_id, sess, role, content, ephemeral, created = r
eph_marker = " [ЭФЕМЕРНОЕ]" if ephemeral else ""
print(f"[{msg_id}] {created} | Сессия: {sess} | Роль: {role.upper()}{eph_marker}")
print("-" * 105)
content_preview = content if content else ""
print(f"{content_preview}")
print("=" * 105)
print("\n")
def purge_chat_context(session_id=None, purge_all=False):
"""
Очистка контекста сообщений:
- По умолчанию: удаляет эфемерные сообщения, осиротевшие превью и сбрасывает стейты сессий.
- purge_all=True (--all): полностью очищает всю таблицу chat_messages и сбрасывает сессии.
"""
with get_connection() as conn:
cursor = conn.cursor()
if purge_all:
if session_id:
cursor.execute("DELETE FROM chat_messages WHERE session_id = ?", (session_id,))
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
else:
cursor.execute("DELETE FROM chat_messages")
cursor.execute("DELETE FROM session_states")
deleted_msgs = cursor.rowcount
conn.commit()
print(f"\n[✓] Полная очистка истории выполнена! Удалено сообщений: {deleted_msgs}\n")
return
query = """
DELETE FROM chat_messages
WHERE is_ephemeral = 1
OR content LIKE '%Предпросмотр изменений%'
OR content LIKE '%Удален пункт:%'
OR content LIKE '%добавлен пункт:%'
"""
if session_id:
cursor.execute(query + " AND session_id = ?", (session_id,))
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
else:
cursor.execute(query)
cursor.execute("DELETE FROM session_states")
deleted_msgs = cursor.rowcount
conn.commit()
print(f"\n[✓] Умная зачистка контекста выполнена! Удалено сообщений: {deleted_msgs}\n")
HELP_TEXT = """ HELP_TEXT = """
CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db) CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db)
@@ -290,9 +383,11 @@ CLI-утилита инспекции и управления SQLite базой
absences [ДД.ММ.ГГГГ] -- Посмотреть список официально отсутствующих из 1С:ЗУП absences [ДД.ММ.ГГГГ] -- Посмотреть список официально отсутствующих из 1С:ЗУП
anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С
rules -- Посмотреть правила Базы Знаний ИИ из SQLite rules -- Посмотреть правила Базы Знаний ИИ из SQLite
prompts -- Посмотреть системные промпты (system_prompts) prompts -- Посмотреть узлы системного промпта (system_prompt_nodes)
tools -- Посмотреть реестр действий инструментов и кнопок (tool_action_registry) tools -- Посмотреть реестр действий инструментов и кнопок (tool_action_registry)
sessions -- Посмотреть активные сессии и превью (session_states) sessions -- Посмотреть активные сессии и превью (session_states)
context [session_id] [--limit N] -- Посмотреть таблицу контекста сообщений чата (chat_messages)
context purge [session_id] [--all] -- Очистить контекст (умная зачистка или полная с флагом --all)
dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel
snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшота по ID или всех за выбранный день snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшота по ID или всех за выбранный день
@@ -300,13 +395,15 @@ CLI-утилита инспекции и управления SQLite базой
python scripts/db_cli.py stats python scripts/db_cli.py stats
python scripts/db_cli.py snapshots 06.08.2026 python scripts/db_cli.py snapshots 06.08.2026
python scripts/db_cli.py scud 06.08.2026 --export-xlsx срез_четверг python scripts/db_cli.py scud 06.08.2026 --export-xlsx срез_четверг
python scripts/db_cli.py scud --snapshot Y20260805-007
python scripts/db_cli.py absences 07.08.2026 python scripts/db_cli.py absences 07.08.2026
python scripts/db_cli.py prompts python scripts/db_cli.py prompts
python scripts/db_cli.py tools python scripts/db_cli.py tools
python scripts/db_cli.py sessions python scripts/db_cli.py sessions
python scripts/db_cli.py context web_session_main --limit 20
python scripts/db_cli.py context purge
python scripts/db_cli.py context purge --all
python scripts/db_cli.py context purge web_session_main --all
python scripts/db_cli.py snapshot del Y20260805-007 python scripts/db_cli.py snapshot del Y20260805-007
python scripts/db_cli.py snapshot del --day 04.08.2026
python scripts/db_cli.py dump my_dump.xlsx python scripts/db_cli.py dump my_dump.xlsx
""" """
@@ -321,12 +418,14 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
add_help=False add_help=False
) )
parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot', 'tools'], help="Основная команда") parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot', 'tools', 'context'], help="Основная команда")
parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del')") parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del', 'purge') или session_id для контекста")
parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, 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('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции")
parser.add_argument('--export-xlsx', type=str, default=None, help="Экспорт среза СКУД в Excel-файл") parser.add_argument('--export-xlsx', type=str, default=None, help="Экспорт среза СКУД в Excel-файл")
parser.add_argument('--day', type=str, default=None, help="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)") parser.add_argument('--day', type=str, default=None, help="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)")
parser.add_argument('--limit', type=int, default=50, help="Лимит выводимых сообщений чата (для команды context)")
parser.add_argument('--all', action='store_true', help="Полная очистка всех сообщений (для команды context purge)")
if len(sys.argv) == 1: if len(sys.argv) == 1:
print_stats() print_stats()
@@ -354,6 +453,14 @@ def main():
print_session_states() print_session_states()
elif args.command == 'tools': elif args.command == 'tools':
print_tool_actions() print_tool_actions()
elif args.command == 'context':
if args.action in ['purge', 'clear']:
is_all = args.all or (args.param == '--all')
sess_id = None if (args.param == '--all' or not args.param) else args.param
purge_chat_context(session_id=sess_id, purge_all=is_all)
else:
sess_id = args.action if args.action else None
print_chat_messages(session_id=sess_id, limit=args.limit)
elif args.command == 'dump': elif args.command == 'dump':
filename = args.param if args.param else "db_dump_full.xlsx" filename = args.param if args.param else "db_dump_full.xlsx"
dump_all_to_excel(filename) dump_all_to_excel(filename)
@@ -370,7 +477,7 @@ def main():
print(f"\n[❌] Ошибка: Неизвестное действие '{args.action}' для команды snapshot.") print(f"\n[❌] Ошибка: Неизвестное действие '{args.action}' для команды snapshot.")
print("Используйте: python scripts/db_cli.py snapshot del [ID или --day 'ДД.ММ.ГГГГ']\n") print("Используйте: python scripts/db_cli.py snapshot del [ID или --day 'ДД.ММ.ГГГГ']\n")
else: else:
print(f"\n[❌] Ошибка: Неизвестная команда.") print("\n[❌] Ошибка: Неизвестная команда.")
print(HELP_TEXT) print(HELP_TEXT)
if __name__ == "__main__": if __name__ == "__main__":
+58
View File
@@ -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)
+23 -8
View File
@@ -2,15 +2,29 @@ import os
OUTPUT_SNAPSHOT = "project_code_snapshot.md" OUTPUT_SNAPSHOT = "project_code_snapshot.md"
# Расширения файлов для включения в снимок # Расширения файлов для включения в снимок (только исходный код)
ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini'} ALLOWED_EXTENSIONS = {'.py', '.json', '.sh', '.ini', '.js', '.html', '.css', '.sql'}
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_orion_ai_v2.tar.gz', 'context_memory.db'}
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: 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('.'): for root, dirs, files in os.walk('.'):
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] 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: if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES:
filepath = os.path.join(root, file) filepath = os.path.join(root, file)
out.write(f"## File: `{filepath}`\n") out.write(f"## File: `{filepath}`\n")
out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n") out.write("```" + ext.replace('.', '') + "\n")
try: try:
with open(filepath, 'r', encoding='utf-8', errors='replace') as f: with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
out.write(f.read()) 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(f"// Ошибка чтения файла: {e}\n")
out.write("\n```\n\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)")