Spaces:
Running
Running
Commit
·
4ad5efa
0
Parent(s):
Єдиний коміт - очищення історії
Browse files- .gitignore +14 -0
- .gradio/certificate.pem +31 -0
- HELP.md +262 -0
- README.md +27 -0
- app.py +100 -0
- config.json +8 -0
- config.py +88 -0
- gradio_config.json +8 -0
- interface.py +69 -0
- jira_assistant.py +545 -0
- modules/ai_analysis/ai_assistant.py +186 -0
- modules/ai_analysis/ai_assistant_integration_mod.py +838 -0
- modules/ai_analysis/ai_assistant_methods.py +137 -0
- modules/ai_analysis/faiss_utils.py +405 -0
- modules/ai_analysis/google_embeddings_utils.py +175 -0
- modules/ai_analysis/indices_initializer.py +83 -0
- modules/ai_analysis/jira_ai_report.py +398 -0
- modules/ai_analysis/jira_hybrid_chat.py +669 -0
- modules/ai_analysis/jira_qa_assistant.py +418 -0
- modules/config/ai_settings.py +47 -0
- modules/config/logging_config.py +52 -0
- modules/config/paths.py +9 -0
- modules/core/app_manager.py +648 -0
- modules/data_analysis/statistics.py +278 -0
- modules/data_analysis/visualizations.py +640 -0
- modules/data_import/csv_importer.py +347 -0
- modules/data_import/jira_api.py +384 -0
- modules/data_management/data_manager.py +500 -0
- modules/data_management/data_processor.py +23 -0
- modules/data_management/hash_utils.py +51 -0
- modules/data_management/index_manager.py +606 -0
- modules/data_management/index_utils.py +457 -0
- modules/data_management/session_manager.py +463 -0
- modules/data_management/unified_index_manager.py +571 -0
- modules/interface/ai_assistant_ui.py +139 -0
- modules/interface/csv_analysis_ui.py +551 -0
- modules/interface/integrations_ui.py +34 -0
- modules/interface/jira_api_ui.py +46 -0
- modules/interface/local_data_helper.py +207 -0
- modules/interface/visualizations_ui.py +112 -0
- modules/reporting/report_generator.py +374 -0
- prompts.py +111 -0
- requirements.txt +173 -0
.gitignore
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
venv/
|
2 |
+
venv_new/
|
3 |
+
__pycache__/
|
4 |
+
*.py[cod]
|
5 |
+
*.class
|
6 |
+
.env
|
7 |
+
.DS_Store
|
8 |
+
.idea/
|
9 |
+
.vscode/
|
10 |
+
*.log
|
11 |
+
venv_new/
|
12 |
+
temp/
|
13 |
+
reports/
|
14 |
+
data/
|
.gradio/certificate.pem
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
-----BEGIN CERTIFICATE-----
|
2 |
+
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
|
3 |
+
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
4 |
+
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
|
5 |
+
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
|
6 |
+
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
|
7 |
+
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
|
8 |
+
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
|
9 |
+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
|
10 |
+
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
|
11 |
+
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
|
12 |
+
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
|
13 |
+
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
|
14 |
+
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
|
15 |
+
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
|
16 |
+
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
|
17 |
+
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
|
18 |
+
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
|
19 |
+
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
|
20 |
+
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
|
21 |
+
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
|
22 |
+
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
|
23 |
+
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
|
24 |
+
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
|
25 |
+
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
|
26 |
+
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
|
27 |
+
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
|
28 |
+
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
|
29 |
+
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
|
30 |
+
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
|
31 |
+
-----END CERTIFICATE-----
|
HELP.md
ADDED
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Jira AI Assistant - Керівництво користувача
|
2 |
+
|
3 |
+
## Загальна інформація
|
4 |
+
|
5 |
+
Jira AI Assistant — це потужний інструмент для аналізу, візуалізації та інтелектуальної обробки даних Jira за допомогою штучного інтелекту. Додаток дозволяє імпортувати дані з CSV-експорту Jira, аналізувати їх, створювати різноманітні візуалізації та використовувати можливості AI для глибокого розуміння стану проєкту.
|
6 |
+
|
7 |
+
### Основні можливості
|
8 |
+
|
9 |
+
- Аналіз CSV-експорту з Jira з виявленням ключових метрик
|
10 |
+
- Генерація візуалізацій різних типів для кращого розуміння даних
|
11 |
+
- AI-аналіз даних з використанням моделей OpenAI та Google Gemini
|
12 |
+
- Гібридний чат з відповідями на питання про проєкт
|
13 |
+
- Автоматична генерація структурованих звітів на основі даних
|
14 |
+
- Збереження результатів аналізу та візуалізацій
|
15 |
+
|
16 |
+
|
17 |
+
## Інтерфейс користувача
|
18 |
+
|
19 |
+
Інтерфейс додатку складається з кількох вкладок, кожна з яких відповідає за певну функціональність:
|
20 |
+
|
21 |
+
1. **CSV Аналіз** - завантаження, ініціалізація та аналіз даних з CSV-файлів
|
22 |
+
2. **Візуалізації** - створення та налаштування візуальних представлень даних
|
23 |
+
3. **AI Асистенти** - робота з AI моделями для аналізу та генерації контенту
|
24 |
+
4. **Jira API** - прямий зв'язок з Jira API (у розробці)
|
25 |
+
5. **Інтеграції** - інтеграція з іншими сервісами (у розробці)
|
26 |
+
|
27 |
+
|
28 |
+
## CSV Аналіз
|
29 |
+
|
30 |
+
Ця вкладка призначена для роботи з даними Jira у форматі CSV.
|
31 |
+
|
32 |
+
### Завантаження CSV-файлу
|
33 |
+
|
34 |
+
1. Перейдіть на вкладку "CSV Аналіз"
|
35 |
+
2. У блоці "Завантаження CSV" натисніть кнопку для вибору файлу або перетягніть файл у відповідну область
|
36 |
+
3. Встановіть значення "Кількість днів для визначення неактивних тікетів" (за замовчуванням 14 днів)
|
37 |
+
|
38 |
+
### Робота з локальними файлами
|
39 |
+
|
40 |
+
Програма може працювати з CSV-файлами, що зберігаються в директорії `current_data`:
|
41 |
+
|
42 |
+
1. Натисніть кнопку "Оновити список файлів" для відображення доступних локальних файлів
|
43 |
+
2. У випадаючому списку "Виберіть файли з директорії current_data" виберіть один або кілька файлів
|
44 |
+
3. Для перегляду вмісту конкретного файлу:
|
45 |
+
- Виберіть файл у списку "Виберіть файл для перегляду"
|
46 |
+
- Натисніть кнопку "Переглянути"
|
47 |
+
|
48 |
+
### Ініціалізація та аналіз даних
|
49 |
+
|
50 |
+
Для аналізу даних натисніть кнопку "Ініціалізація та Аналіз". Процес виконає наступні кроки:
|
51 |
+
|
52 |
+
1. Завантаження та об'єднання вибраних файлів (якщо вибрано кілька)
|
53 |
+
2. Обробка даних для аналізу
|
54 |
+
3. Генерація статистики та виявлення неактивних тікетів
|
55 |
+
4. Відображення звіту з результатами
|
56 |
+
|
57 |
+
Звіт містить:
|
58 |
+
- Загальну статистику проєкту (кількість тікетів, розподіл за статусами, типами, пріоритетами)
|
59 |
+
- Аналіз неактивних тікетів (тікети без змін протягом вказаного періоду)
|
60 |
+
- Рекомендації
|
61 |
+
|
62 |
+
### Очищення тимчасових даних
|
63 |
+
|
64 |
+
У розділі "Обслуговування" можна виконати очищення тимчасових даних:
|
65 |
+
|
66 |
+
1. Розгорніть секцію "Обслуговування"
|
67 |
+
2. Натисніть кнопку "Очистити тимчасові дані"
|
68 |
+
|
69 |
+
Ця функція видаляє всі тимчасові файли, включаючи індекси, сесії та звіти, але не видаляє файли в директорії `current_data`.
|
70 |
+
|
71 |
+
## Візуалізації
|
72 |
+
|
73 |
+
Вкладка "Візуалізації" дозволяє створювати різні графічні представлення даних.
|
74 |
+
|
75 |
+
### Створення візуалізацій
|
76 |
+
|
77 |
+
1. Виберіть тип візуалізації зі списку:
|
78 |
+
- **Статуси** - розподіл тікетів за статусами
|
79 |
+
- **Пріоритети** - розподіл тікетів за пріоритетами
|
80 |
+
- **Типи тікетів** - розподіл за типами (Bugs, Tasks, Stories тощо)
|
81 |
+
- **Призначені користувачі** - розподіл тікетів за виконавцями
|
82 |
+
- **Активність створення** - кількість нових тікетів за період
|
83 |
+
- **Активність оновлення** - кількість оновлених тікетів за період
|
84 |
+
- **Кумулятивне створення** - наростаюча кількість тікетів з часом
|
85 |
+
- **Неактивні тікети** - аналіз тікетів без руху
|
86 |
+
- **Теплова карта: Типи/Статуси** - взаємозв'язок між типами та статусами
|
87 |
+
- **Часова шкала проекту** - загальна шкала активності
|
88 |
+
- **Склад статусів з часом** - зміна складу статусів з часом
|
89 |
+
|
90 |
+
2. Налаштуйте параметри візуалізації (в акордеоні "Параметри візуалізації"):
|
91 |
+
- **Ліміт для топ-візуалізацій** - кількість елементів для відображення (для топ-списків)
|
92 |
+
- **Групування для часових діаграм** - рівень деталізації (день, тиждень, місяць)
|
93 |
+
|
94 |
+
3. Натисніть кнопку "Генерувати" для створення візуалізації
|
95 |
+
|
96 |
+
### Збереження візуалізацій
|
97 |
+
|
98 |
+
Щоб зберегти створену візуалізацію:
|
99 |
+
|
100 |
+
1. Введіть ім'я файлу (або залиште порожнім для автоматичного імені)
|
101 |
+
2. Натисніть кнопку "Зберегти візуалізацію"
|
102 |
+
3. Візуалізація буде збережена в директорії `reports/visualizations`
|
103 |
+
|
104 |
+
## AI Асистенти
|
105 |
+
|
106 |
+
Вкладка "AI Асистенти" надає доступ до функцій аналізу даних за допомогою штучного інтелекту.
|
107 |
+
|
108 |
+
### Налаштування параметрів
|
109 |
+
|
110 |
+
Для всіх режимів AI можна налаштувати:
|
111 |
+
|
112 |
+
- **Модель LLM** - вибір між моделями:
|
113 |
+
- `gemini` - використовує Google Gemini моделі
|
114 |
+
- `openai` - використовує OpenAI моделі (GPT)
|
115 |
+
- **Температура** - параметр для контролю креативності відповідей (вищі значення = більше креативності, нижчі = більше детермінованості)
|
116 |
+
|
117 |
+
### Ініціалізація індексів
|
118 |
+
|
119 |
+
Перед використанням режиму Гібридного чату необхідно створити індекси для ефективного пошуку:
|
120 |
+
|
121 |
+
1. Переконайтеся, що дані вже завантажені через вкладку "CSV Аналіз"
|
122 |
+
2. Натисніть кнопку "Ініціалізувати індекси"
|
123 |
+
3. Дочекайтеся повідомлення про успішне створення індексів
|
124 |
+
|
125 |
+
Цей крок створює:
|
126 |
+
- Векторні індекси FAISS для семантичного (смислового) пошуку
|
127 |
+
- BM25 індекси для пошуку за ключовими словами
|
128 |
+
|
129 |
+
### Режими роботи з AI
|
130 |
+
|
131 |
+
#### Q/A з повним контекстом
|
132 |
+
|
133 |
+
Режим для загальних питань про проєкт, який надає доступ до всіх даних одночасно:
|
134 |
+
|
135 |
+
1. Введіть питання у відповідне поле
|
136 |
+
2. Натисніть "Отримати відповідь"
|
137 |
+
3. Система аналізує всі дані та надає комплексну відповідь
|
138 |
+
|
139 |
+
Приклади питань:
|
140 |
+
- "Які тікети мають найвищий пріоритет?"
|
141 |
+
- "Скільки помилок було виправлено за останній місяць?"
|
142 |
+
- "Хто найактивніший розробник у проєкті?"
|
143 |
+
|
144 |
+
#### Гібридний чат
|
145 |
+
|
146 |
+
Режим діалогу з системою, який використовує комбінацію BM25 і векторного пошуку:
|
147 |
+
|
148 |
+
1. Введіть питання у поле для повідомлення
|
149 |
+
2. Натисніть Enter або Shift+Enter для відправки
|
150 |
+
3. ��истема відповідає на основі аналізу даних
|
151 |
+
4. Можна вести діалог з послідовними питаннями
|
152 |
+
|
153 |
+
Переваги:
|
154 |
+
- Підтримує контекст розмови (враховує попередні питання та відповіді)
|
155 |
+
- Показує релевантні документи/тікети для кожної відповіді
|
156 |
+
- Оптимальний для детальних специфічних запитань
|
157 |
+
|
158 |
+
#### Генерація звіту
|
159 |
+
|
160 |
+
Режим для автоматичного створення структурованого аналітичного звіту:
|
161 |
+
|
162 |
+
1. Виберіть формат звіту (markdown або html)
|
163 |
+
2. Натисніть "Згенерувати звіт"
|
164 |
+
3. Система аналізує всі дані та створює детальний звіт
|
165 |
+
|
166 |
+
Звіт зазвичай містить:
|
167 |
+
- Короткий огляд проєкту
|
168 |
+
- Аналіз поточного стану
|
169 |
+
- Виявлені проблеми та ризики
|
170 |
+
- Рекомендації для покращення процесу
|
171 |
+
- Висновки та наступні кроки
|
172 |
+
|
173 |
+
|
174 |
+
## Експорт даних з Jira у CSV-формат
|
175 |
+
|
176 |
+
Для коректної роботи з Jira AI Assistant необхідно правильно експортувати дані з Jira у форматі CSV. Нижче наведено детальні інструкції з експорту.
|
177 |
+
|
178 |
+
### Пошук та налаштування даних для експорту
|
179 |
+
|
180 |
+
1. **Відкрийте Jira** та авторизуйтеся у системі
|
181 |
+
2. **Перейдіть до функції пошуку**: натисніть "Issues" у верхньому меню, потім виберіть "Search for issues"
|
182 |
+
3. **Налаштуйте фільтри пошуку** для відбору потрібних тікетів:
|
183 |
+
- Виберіть проєкт (наприклад, "IEE DS") з випадаючого списку
|
184 |
+
- Вкажіть тип завдань ("Type") або залиште "All"
|
185 |
+
- Вкажіть статус завдань ("Status") або залиште "All"
|
186 |
+
- За потреби вкажіть виконавця ("Assignee")
|
187 |
+
- Використовуйте поле пошуку для конкретного тексту
|
188 |
+
- Для більш складних запитів натисніть "Advanced" і використовуйте JQL-запити
|
189 |
+
|
190 |
+
4. **Натисніть кнопку "Search"** для отримання результатів
|
191 |
+
|
192 |
+
### Експорт результатів пошуку у CSV
|
193 |
+
|
194 |
+
1. **У результатах пошуку натисніть кнопку "Export"** (знаходиться у правому верхньому куті)
|
195 |
+
2. **Виберіть "CSV (Current fields)"** або "CSV (All fields)" залежно від того, які дані вам потрібні:
|
196 |
+
- "Current fields" - експортує тільки поля, що відображаються у поточному представленні
|
197 |
+
- "All fields" - експортує всі доступні поля (рекомендовано для повного аналізу)
|
198 |
+
|
199 |
+
3. **Налаштуйте опції експорту**:
|
200 |
+
- Переконайтеся, що включені всі важливі поля: Issue key, Summary, Status, Issue Type, Priority, Created, Updated, Description, Assignee, Reporter
|
201 |
+
- Якщо використовуєте власні поля (custom fields), переконайтеся, що вони також включені
|
202 |
+
|
203 |
+
4. **Підтвердіть експорт** і збережіть CSV-файл на вашому комп'ютері
|
204 |
+
|
205 |
+
### Рекомендації щодо експорту
|
206 |
+
|
207 |
+
- **Експортуйте всі можливі поля**, особливо якщо плануєте використовувати AI аналіз. Більше даних дозволяє отримати більш глибокі та точні інсайти.
|
208 |
+
- **Включіть поле Description** для аналізу текстового вмісту тікетів.
|
209 |
+
- **Включіть поля з коментарями**, якщо вони доступні у вашій конфігурації Jira.
|
210 |
+
- **Експортуйте вкладення або посилання**, якщо вони важливі для аналізу.
|
211 |
+
- **Для великих проєктів** розгляньте можливість створення кількох експортів з різними наборами фільтрів для більш цілеспрямованого аналізу.
|
212 |
+
|
213 |
+
|
214 |
+
## Поради з використання
|
215 |
+
|
216 |
+
### Оптимальні практики
|
217 |
+
|
218 |
+
1. **Підготовка даних**:
|
219 |
+
- Експортуйте з Jira максимально повний набір даних з усіма важливими полям��
|
220 |
+
- Упевніться, що CSV-файл містить колонки: Issue key, Summary, Status, Issue Type, Priority, Created, Updated
|
221 |
+
|
222 |
+
2. **Правильна послідовність дій**:
|
223 |
+
- Спочатку завантажте і проаналізуйте дані (вкладка "CSV Аналіз")
|
224 |
+
- Створіть необхідні візуалізації (вкладка "Візуалізації")
|
225 |
+
- Ініціалізуйте індекси перед використанням AI асистентів
|
226 |
+
- Використовуйте функції AI для глибшого аналізу
|
227 |
+
|
228 |
+
3. **Вибір режиму AI**:
|
229 |
+
- "Q/A з повним контекстом" - для загальних питань про проєкт
|
230 |
+
- "Гібридний чат" - для детальних питань з контекстом розмови
|
231 |
+
- "Генерація звіту" - для створення структурованих звітів
|
232 |
+
|
233 |
+
### Вирішення поширених проблем
|
234 |
+
|
235 |
+
1. **Не вдається завантажити CSV**:
|
236 |
+
- Перевірте формат CSV-файлу та наявність необхідних колонок
|
237 |
+
- Переконайтеся, що файл не порожній і не пошкоджений
|
238 |
+
- Спробуйте скопіювати файл у директорію `current_data` і використати через локальні файли
|
239 |
+
|
240 |
+
2. **Помилки при створенні індексів**:
|
241 |
+
- Переконайтеся, що встановлені всі необхідні бібліотеки для AI
|
242 |
+
- Очистіть тимчасові дані та спробуйте знову
|
243 |
+
- Перевірте наявність достатньої кількості вільного місця на диску
|
244 |
+
|
245 |
+
3. **AI асистент не відповідає**:
|
246 |
+
- Перевірте наявність та валідність API ключів (OpenAI або Gemini)
|
247 |
+
- Для гібридного чату переконайтеся, що індекси успішно ініціалізовані
|
248 |
+
|
249 |
+
|
250 |
+
## Додаткова інформація
|
251 |
+
|
252 |
+
Для додаткових запитань і технічної підтримки звертайтеся до розробника (szabolotnii@healthprecision.com).
|
253 |
+
|
254 |
+
---
|
255 |
+
|
256 |
+
### Глосарій
|
257 |
+
|
258 |
+
- **CSV-файл** - формат файлу для зберігання табличних даних, який можна експортувати з Jira
|
259 |
+
- **Неактивні тікети** - тікети, які не оновлювалися протягом визначеного періоду
|
260 |
+
- **FAISS** - бібліотека для ефективного пошуку схожих векторів, використовується для семантичного пошуку
|
261 |
+
- **BM25** - алгоритм ранжування для пошуку за ключовими словами
|
262 |
+
- **Гібридний пошук** - комбінація BM25 та векторного пошуку для покращення релевантності результатів
|
README.md
ADDED
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
---
|
2 |
+
title: Jira AI Assistant
|
3 |
+
emoji: 🔍
|
4 |
+
colorFrom: blue
|
5 |
+
colorTo: indigo
|
6 |
+
sdk: gradio
|
7 |
+
sdk_version: 5.20.0
|
8 |
+
app_file: app.py
|
9 |
+
pinned: false
|
10 |
+
---
|
11 |
+
|
12 |
+
# Jira AI Assistant
|
13 |
+
|
14 |
+
Інструмент для аналізу та візуалізації даних з Jira за допомогою AI.
|
15 |
+
|
16 |
+
## Можливості
|
17 |
+
|
18 |
+
- Аналіз CSV-експорту з Jira
|
19 |
+
- Генерація звітів та візуалізацій
|
20 |
+
- AI аналіз даних Jira
|
21 |
+
- Інтеграція з OpenAI та Google Gemini
|
22 |
+
|
23 |
+
## Використання
|
24 |
+
|
25 |
+
1. Завантажте CSV-файл
|
26 |
+
2. Оберіть режим аналізу
|
27 |
+
3. Отримайте результати та візуалізації
|
app.py
ADDED
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import logging
|
3 |
+
from pathlib import Path
|
4 |
+
import sys
|
5 |
+
|
6 |
+
# Додаємо поточну директорію до PYTHONPATH для правильного імпорту модулів
|
7 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
8 |
+
if current_dir not in sys.path:
|
9 |
+
sys.path.append(current_dir)
|
10 |
+
|
11 |
+
# Налаштування логування
|
12 |
+
logging.basicConfig(
|
13 |
+
level=logging.INFO,
|
14 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
15 |
+
handlers=[
|
16 |
+
logging.FileHandler("huggingface_jira_assistant.log"),
|
17 |
+
logging.StreamHandler(sys.stdout)
|
18 |
+
]
|
19 |
+
)
|
20 |
+
logger = logging.getLogger("jira_assistant_hf")
|
21 |
+
|
22 |
+
# Створення необхідних директорій
|
23 |
+
for directory in ["data", "reports", "temp", "logs", "temp/indices", "current_data"]:
|
24 |
+
Path(directory).mkdir(exist_ok=True, parents=True)
|
25 |
+
|
26 |
+
logger.info(f"Робоча директорія: {os.getcwd()}")
|
27 |
+
logger.info(f"Вміст робочої директорії: {os.listdir('.')}")
|
28 |
+
logger.info(f"Python path: {sys.path}")
|
29 |
+
|
30 |
+
# Перевірка середовища Hugging Face
|
31 |
+
is_huggingface = os.environ.get("SPACE_ID") is not None
|
32 |
+
if is_huggingface:
|
33 |
+
logger.info("Виявлено середовище Hugging Face Spaces")
|
34 |
+
|
35 |
+
# Спроба імпорту основного додатку
|
36 |
+
try:
|
37 |
+
# Імпорт уніфікованого менеджера індексів
|
38 |
+
from modules.data_management.unified_index_manager import UnifiedIndexManager
|
39 |
+
|
40 |
+
# Створюємо глобальний екземпляр менеджера індексів
|
41 |
+
# Це дозволить використовувати один і той же менеджер в різних модулях
|
42 |
+
index_manager = UnifiedIndexManager()
|
43 |
+
|
44 |
+
# Додаємо менеджер індексів до глобальних змінних
|
45 |
+
import builtins
|
46 |
+
builtins.index_manager = index_manager
|
47 |
+
|
48 |
+
# Імпорт основного додатку з перейменованого файлу
|
49 |
+
from jira_assistant import JiraAssistantApp
|
50 |
+
from interface import launch_interface
|
51 |
+
|
52 |
+
# Створення екземпляру додатку
|
53 |
+
app = JiraAssistantApp()
|
54 |
+
|
55 |
+
# Передаємо менеджер індексів у додаток
|
56 |
+
app.index_manager = index_manager
|
57 |
+
|
58 |
+
# Отримання інтерфейсу Gradio
|
59 |
+
interface = launch_interface(app)
|
60 |
+
|
61 |
+
# Запуск інтерфейсу
|
62 |
+
if __name__ == "__main__":
|
63 |
+
interface.launch(
|
64 |
+
server_name="0.0.0.0",
|
65 |
+
server_port=7860,
|
66 |
+
share=False
|
67 |
+
)
|
68 |
+
|
69 |
+
except Exception as e:
|
70 |
+
import traceback
|
71 |
+
logger.error(f"Помилка при ініціалізації програми: {e}")
|
72 |
+
logger.error(traceback.format_exc())
|
73 |
+
|
74 |
+
# Створення спрощеного інтерфейсу при помилці
|
75 |
+
import gradio as gr
|
76 |
+
|
77 |
+
def simplified_interface():
|
78 |
+
with gr.Blocks(title="Jira AI Assistant") as interface:
|
79 |
+
gr.Markdown("# 🔍 Jira AI Assistant")
|
80 |
+
gr.Markdown(f"""
|
81 |
+
## ⚠️ Помилка запуску
|
82 |
+
|
83 |
+
Виникла помилка при запуску програми. Перевірте логи для детальної інформації.
|
84 |
+
|
85 |
+
Помилка: {str(e)}
|
86 |
+
|
87 |
+
Вміст директорії: {os.listdir('.')}
|
88 |
+
Python path: {sys.path}
|
89 |
+
""")
|
90 |
+
|
91 |
+
return interface
|
92 |
+
|
93 |
+
interface = simplified_interface()
|
94 |
+
|
95 |
+
if __name__ == "__main__":
|
96 |
+
interface.launch(
|
97 |
+
server_name="0.0.0.0",
|
98 |
+
server_port=7860,
|
99 |
+
share=False
|
100 |
+
)
|
config.json
ADDED
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"theme": "default",
|
3 |
+
"share": false,
|
4 |
+
"additional_options": {
|
5 |
+
"show_api": false,
|
6 |
+
"show_error": true
|
7 |
+
}
|
8 |
+
}
|
config.py
ADDED
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
from pathlib import Path
|
3 |
+
from dotenv import load_dotenv
|
4 |
+
|
5 |
+
# Завантаження змінних середовища з .env файлу, якщо він існує
|
6 |
+
load_dotenv()
|
7 |
+
|
8 |
+
# Базові шляхи
|
9 |
+
BASE_DIR = Path(__file__).parent.absolute()
|
10 |
+
DATA_DIR = BASE_DIR / "data"
|
11 |
+
REPORTS_DIR = BASE_DIR / "reports"
|
12 |
+
TEMP_DIR = BASE_DIR / "temp"
|
13 |
+
LOG_DIR = BASE_DIR / "logs"
|
14 |
+
|
15 |
+
# Створення директорій, якщо вони не існують
|
16 |
+
DATA_DIR.mkdir(exist_ok=True, parents=True)
|
17 |
+
REPORTS_DIR.mkdir(exist_ok=True, parents=True)
|
18 |
+
TEMP_DIR.mkdir(exist_ok=True, parents=True)
|
19 |
+
LOG_DIR.mkdir(exist_ok=True, parents=True)
|
20 |
+
|
21 |
+
# Налаштування додатку
|
22 |
+
APP_NAME = "Jira AI Assistant"
|
23 |
+
APP_VERSION = "1.0.0"
|
24 |
+
DEFAULT_INACTIVE_DAYS = 14
|
25 |
+
MAX_RESULTS = 500
|
26 |
+
|
27 |
+
# API ключі та налаштування
|
28 |
+
JIRA_URL = os.getenv("JIRA_URL", "")
|
29 |
+
JIRA_USERNAME = os.getenv("JIRA_USERNAME", "")
|
30 |
+
JIRA_API_TOKEN = os.getenv("JIRA_API_TOKEN", "")
|
31 |
+
|
32 |
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
|
33 |
+
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-3.5-turbo")
|
34 |
+
|
35 |
+
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
|
36 |
+
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-pro")
|
37 |
+
|
38 |
+
SLACK_BOT_TOKEN = os.getenv("SLACK_BOT_TOKEN", "")
|
39 |
+
|
40 |
+
# Налаштування логування
|
41 |
+
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
42 |
+
LOG_FILE = os.getenv("LOG_FILE", LOG_DIR / f"{APP_NAME.lower().replace(' ', '_')}.log")
|
43 |
+
|
44 |
+
# Додаткові налаштування для Gradio
|
45 |
+
GRADIO_THEME = "huggingface" # Доступні теми: default, huggingface, grass, peach
|
46 |
+
GRADIO_AUTH = [] # Список кортежів (username, password) для авторизації
|
47 |
+
GRADIO_SHARE = False # Чи використовувати публічний URL для додатку
|
48 |
+
|
49 |
+
# Функція для збереження налаштувань у конфігураційний файл
|
50 |
+
def save_config():
|
51 |
+
import json
|
52 |
+
|
53 |
+
config = {
|
54 |
+
"app_name": APP_NAME,
|
55 |
+
"version": APP_VERSION,
|
56 |
+
"data_dir": str(DATA_DIR),
|
57 |
+
"reports_dir": str(REPORTS_DIR),
|
58 |
+
"temp_dir": str(TEMP_DIR),
|
59 |
+
"log_dir": str(LOG_DIR),
|
60 |
+
"log_level": LOG_LEVEL,
|
61 |
+
"default_inactive_days": DEFAULT_INACTIVE_DAYS,
|
62 |
+
"openai_model": OPENAI_MODEL,
|
63 |
+
"gemini_model": GEMINI_MODEL,
|
64 |
+
"max_results": MAX_RESULTS,
|
65 |
+
"gradio_theme": GRADIO_THEME,
|
66 |
+
"gradio_share": GRADIO_SHARE
|
67 |
+
}
|
68 |
+
|
69 |
+
with open(BASE_DIR / "config.json", "w", encoding="utf-8") as f:
|
70 |
+
json.dump(config, f, indent=2)
|
71 |
+
|
72 |
+
# Функція для завантаження налаштувань з конфігураційного файлу
|
73 |
+
def load_config():
|
74 |
+
import json
|
75 |
+
|
76 |
+
config_file = BASE_DIR / "config.json"
|
77 |
+
|
78 |
+
if config_file.exists():
|
79 |
+
with open(config_file, "r", encoding="utf-8") as f:
|
80 |
+
config = json.load(f)
|
81 |
+
|
82 |
+
return config
|
83 |
+
|
84 |
+
return None
|
85 |
+
|
86 |
+
# Створення конфігураційного файлу при імпорті, якщо він не існує
|
87 |
+
if not (BASE_DIR / "config.json").exists():
|
88 |
+
save_config()
|
gradio_config.json
ADDED
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"theme": "gradio/glass",
|
3 |
+
"share": true,
|
4 |
+
"additional_options": {
|
5 |
+
"show_api": false,
|
6 |
+
"show_error": true
|
7 |
+
}
|
8 |
+
}
|
interface.py
ADDED
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import gradio as gr
|
2 |
+
import logging
|
3 |
+
from pathlib import Path
|
4 |
+
|
5 |
+
# Імпортуємо вкладки з підмодулів
|
6 |
+
from modules.interface.csv_analysis_ui import create_csv_analysis_tab
|
7 |
+
from modules.interface.visualizations_ui import create_visualizations_tab
|
8 |
+
from modules.interface.jira_api_ui import create_jira_api_tab
|
9 |
+
from modules.interface.ai_assistant_ui import create_ai_assistant_tab
|
10 |
+
from modules.interface.integrations_ui import create_integrations_tab
|
11 |
+
|
12 |
+
logger = logging.getLogger("jira_assistant_interface")
|
13 |
+
|
14 |
+
def create_help_tab(app):
|
15 |
+
"""
|
16 |
+
Створює вкладку 'Довідка' з інформацією з HELP.md.
|
17 |
+
"""
|
18 |
+
with gr.Tab("Довідка"):
|
19 |
+
try:
|
20 |
+
# Шлях до файлу HELP.md
|
21 |
+
help_file_path = Path("HELP.md")
|
22 |
+
|
23 |
+
# Перевіряємо, чи існує файл
|
24 |
+
if help_file_path.exists():
|
25 |
+
# Читаємо вміст файлу
|
26 |
+
with open(help_file_path, "r", encoding="utf-8") as f:
|
27 |
+
help_content = f.read()
|
28 |
+
|
29 |
+
# Відображаємо вміст як Markdown
|
30 |
+
with gr.Blocks():
|
31 |
+
gr.Markdown(help_content)
|
32 |
+
else:
|
33 |
+
gr.Markdown("# Довідка недоступна")
|
34 |
+
gr.Markdown("Файл HELP.md не знайдено. Перевірте, чи існує файл у кореневій директорії проєкту.")
|
35 |
+
except Exception as e:
|
36 |
+
logger.error(f"Помилка при завантаженні файлу довідки: {e}")
|
37 |
+
gr.Markdown("# Помилка при завантаженні довідки")
|
38 |
+
gr.Markdown(f"Виникла помилка: {str(e)}")
|
39 |
+
|
40 |
+
def launch_interface(app):
|
41 |
+
"""
|
42 |
+
Запуск інтерфейсу користувача Gradio
|
43 |
+
|
44 |
+
Args:
|
45 |
+
app: Екземпляр JiraAssistantApp
|
46 |
+
"""
|
47 |
+
interface = gr.Blocks(title="Jira AI Assistant")
|
48 |
+
|
49 |
+
with interface:
|
50 |
+
gr.Markdown("# 🔍 Jira AI Assistant")
|
51 |
+
|
52 |
+
# Перевіряємо, чи додаток має необхідні атрибути
|
53 |
+
if not hasattr(app, 'last_loaded_csv'):
|
54 |
+
app.last_loaded_csv = None
|
55 |
+
if not hasattr(app, 'current_data'):
|
56 |
+
app.current_data = None
|
57 |
+
if not hasattr(app, 'indices_path'):
|
58 |
+
app.indices_path = None
|
59 |
+
|
60 |
+
with gr.Tabs() as tabs:
|
61 |
+
# Створюємо вкладки
|
62 |
+
create_csv_analysis_tab(app)
|
63 |
+
create_visualizations_tab(app)
|
64 |
+
create_ai_assistant_tab(app)
|
65 |
+
create_jira_api_tab(app)
|
66 |
+
create_integrations_tab(app)
|
67 |
+
create_help_tab(app) # Додана нова вкладка з довідкою
|
68 |
+
|
69 |
+
return interface
|
jira_assistant.py
ADDED
@@ -0,0 +1,545 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import logging
|
3 |
+
from pathlib import Path
|
4 |
+
from datetime import datetime
|
5 |
+
import traceback
|
6 |
+
import builtins
|
7 |
+
import uuid
|
8 |
+
import json
|
9 |
+
|
10 |
+
# Налаштування логування
|
11 |
+
logging.basicConfig(
|
12 |
+
level=logging.INFO,
|
13 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
14 |
+
handlers=[
|
15 |
+
logging.FileHandler("jira_assistant.log"),
|
16 |
+
logging.StreamHandler()
|
17 |
+
]
|
18 |
+
)
|
19 |
+
logger = logging.getLogger("jira_assistant")
|
20 |
+
|
21 |
+
# Створення необхідних директорій
|
22 |
+
for directory in ["data", "reports", "temp", "logs"]:
|
23 |
+
Path(directory).mkdir(exist_ok=True, parents=True)
|
24 |
+
|
25 |
+
# Імпорт необхідних модулів
|
26 |
+
from modules.data_import.csv_importer import JiraCsvImporter
|
27 |
+
from modules.data_analysis.statistics import JiraDataAnalyzer
|
28 |
+
from modules.data_analysis.visualizations import JiraVisualizer
|
29 |
+
from modules.reporting.report_generator import ReportGenerator
|
30 |
+
from modules.core.app_manager import AppManager
|
31 |
+
|
32 |
+
from modules.ai_analysis.jira_hybrid_chat import JiraHybridChat
|
33 |
+
|
34 |
+
|
35 |
+
class JiraAssistantApp:
|
36 |
+
"""
|
37 |
+
Головний клас додатку, який координує роботу всіх компонентів
|
38 |
+
"""
|
39 |
+
def __init__(self):
|
40 |
+
try:
|
41 |
+
# Отримуємо глобальний менеджер індексів
|
42 |
+
self.index_manager = builtins.index_manager
|
43 |
+
logger.info("Використовуємо глобальний менеджер індексів")
|
44 |
+
except AttributeError:
|
45 |
+
# Якщо глобальний менеджер не знайдено, створюємо новий
|
46 |
+
from modules.data_management.unified_index_manager import UnifiedIndexManager
|
47 |
+
self.index_manager = UnifiedIndexManager()
|
48 |
+
logger.info("Створено новий менеджер індексів")
|
49 |
+
|
50 |
+
self.app_manager = AppManager()
|
51 |
+
self.current_data = None
|
52 |
+
self.current_analysis = None
|
53 |
+
self.visualizations = None
|
54 |
+
self.last_loaded_csv = None
|
55 |
+
self.current_session_id = None
|
56 |
+
|
57 |
+
def analyze_csv_file(self, file_path, inactive_days=14, include_ai=False, api_key=None, model_type="openai", skip_indexing=True):
|
58 |
+
"""
|
59 |
+
Аналіз CSV-файлу Jira без створення індексів.
|
60 |
+
|
61 |
+
Args:
|
62 |
+
file_path (str): Шлях до CSV-файлу
|
63 |
+
inactive_days (int): Кількість днів для визначення неактивних тікетів
|
64 |
+
include_ai (bool): Чи використовувати AI-аналіз
|
65 |
+
api_key (str): API ключ для LLM (якщо include_ai=True)
|
66 |
+
model_type (str): Тип моделі LLM ("openai" або "gemini")
|
67 |
+
skip_indexing (bool): Пропустити створення індексів FAISS/BM25
|
68 |
+
|
69 |
+
Returns:
|
70 |
+
dict: Результати аналізу
|
71 |
+
"""
|
72 |
+
try:
|
73 |
+
logger.info(f"Аналіз файлу: {file_path}")
|
74 |
+
|
75 |
+
# Генеруємо ідентифікатор сесії
|
76 |
+
import uuid
|
77 |
+
from datetime import datetime
|
78 |
+
self.current_session_id = f"{uuid.uuid4()}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
79 |
+
|
80 |
+
# Завантаження даних
|
81 |
+
from modules.data_import.csv_importer import JiraCsvImporter
|
82 |
+
csv_importer = JiraCsvImporter(file_path)
|
83 |
+
self.current_data = csv_importer.load_data()
|
84 |
+
|
85 |
+
if self.current_data is None:
|
86 |
+
return {"error": "Не вдалося завантажити дані з CSV-файлу"}
|
87 |
+
|
88 |
+
# Створюємо індекси для даних, тільки якщо не вказано пропустити
|
89 |
+
if not skip_indexing:
|
90 |
+
indices_result = self.index_manager.get_or_create_indices(
|
91 |
+
self.current_data,
|
92 |
+
self.current_session_id
|
93 |
+
)
|
94 |
+
|
95 |
+
if isinstance(indices_result, dict) and "error" not in indices_result:
|
96 |
+
logger.info(f"Індекси успішно створено: {indices_result.get('indices_dir', 'невідомо')}")
|
97 |
+
self.current_indices_dir = indices_result.get("indices_dir", None)
|
98 |
+
self.indices_path = indices_result.get("indices_dir", None)
|
99 |
+
else:
|
100 |
+
logger.info("Створення індексів пропущено згідно з налаштуваннями")
|
101 |
+
|
102 |
+
# Аналіз даних
|
103 |
+
from modules.data_analysis.statistics import JiraDataAnalyzer
|
104 |
+
analyzer = JiraDataAnalyzer(self.current_data)
|
105 |
+
|
106 |
+
# Базова статистика
|
107 |
+
stats = analyzer.generate_basic_statistics()
|
108 |
+
|
109 |
+
# Аналіз неактивних тікетів
|
110 |
+
inactive_issues = analyzer.analyze_inactive_issues(days=inactive_days)
|
111 |
+
|
112 |
+
# Створення візуалізацій
|
113 |
+
from modules.data_analysis.visualizations import JiraVisualizer
|
114 |
+
visualizer = JiraVisualizer(self.current_data)
|
115 |
+
self.visualizations = {
|
116 |
+
"status": visualizer.plot_status_counts(),
|
117 |
+
"priority": visualizer.plot_priority_counts(),
|
118 |
+
"type": visualizer.plot_type_counts(),
|
119 |
+
"created_timeline": visualizer.plot_created_timeline(),
|
120 |
+
"inactive": visualizer.plot_inactive_issues(days=inactive_days)
|
121 |
+
}
|
122 |
+
|
123 |
+
# AI аналіз, якщо потрібен
|
124 |
+
ai_analysis = None
|
125 |
+
if include_ai and api_key:
|
126 |
+
from modules.ai_analysis.llm_connector import LLMConnector
|
127 |
+
llm = LLMConnector(api_key=api_key, model_type=model_type)
|
128 |
+
ai_analysis = llm.analyze_jira_data(stats, inactive_issues)
|
129 |
+
|
130 |
+
# Генерація звіту
|
131 |
+
from modules.reporting.report_generator import ReportGenerator
|
132 |
+
report_generator = ReportGenerator(self.current_data, stats, inactive_issues, ai_analysis)
|
133 |
+
report = report_generator.create_markdown_report(inactive_days=inactive_days)
|
134 |
+
|
135 |
+
# Зберігаємо поточний аналіз
|
136 |
+
self.current_analysis = {
|
137 |
+
"stats": stats,
|
138 |
+
"inactive_issues": inactive_issues,
|
139 |
+
"report": report,
|
140 |
+
"ai_analysis": ai_analysis
|
141 |
+
}
|
142 |
+
|
143 |
+
# Зберігаємо інформацію про сесію
|
144 |
+
session_info = {
|
145 |
+
"session_id": self.current_session_id,
|
146 |
+
"file_path": str(file_path),
|
147 |
+
"file_name": Path(file_path).name,
|
148 |
+
"rows_count": len(self.current_data),
|
149 |
+
"columns_count": len(self.current_data.columns),
|
150 |
+
"indices_dir": getattr(self, "current_indices_dir", None),
|
151 |
+
"created_at": datetime.now().isoformat()
|
152 |
+
}
|
153 |
+
|
154 |
+
# Зберігаємо інформацію про сесію у файл
|
155 |
+
sessions_dir = Path("temp/sessions")
|
156 |
+
sessions_dir.mkdir(exist_ok=True, parents=True)
|
157 |
+
session_file = sessions_dir / f"{self.current_session_id}.json"
|
158 |
+
|
159 |
+
with open(session_file, "w", encoding="utf-8") as f:
|
160 |
+
json.dump(session_info, f, ensure_ascii=False, indent=2)
|
161 |
+
|
162 |
+
return {
|
163 |
+
"report": report,
|
164 |
+
"visualizations": self.visualizations,
|
165 |
+
"ai_analysis": ai_analysis,
|
166 |
+
"error": None,
|
167 |
+
"session_id": self.current_session_id
|
168 |
+
}
|
169 |
+
|
170 |
+
except Exception as e:
|
171 |
+
error_msg = f"Помилка аналізу: {str(e)}\n\n{traceback.format_exc()}"
|
172 |
+
logger.error(error_msg)
|
173 |
+
return {"error": error_msg}
|
174 |
+
|
175 |
+
def save_report(self, format_type="markdown", include_visualizations=True, filepath=None):
|
176 |
+
"""
|
177 |
+
Збереження звіту у файл
|
178 |
+
|
179 |
+
Args:
|
180 |
+
format_type (str): Формат звіту ("markdown", "html", "pdf")
|
181 |
+
include_visualizations (bool): Чи включати візуалізації у звіт
|
182 |
+
filepath (str): Шлях для збереження файлу
|
183 |
+
|
184 |
+
Returns:
|
185 |
+
str: Шлях до збереженого файлу або повідомлення про помилку
|
186 |
+
"""
|
187 |
+
try:
|
188 |
+
if not self.current_analysis or "report" not in self.current_analysis:
|
189 |
+
return "Помилка: спочатку виконайте аналіз даних"
|
190 |
+
|
191 |
+
# Створення імені файлу, якщо не вказано
|
192 |
+
if not filepath:
|
193 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
194 |
+
report_filename = f"jira_report_{timestamp}"
|
195 |
+
reports_dir = Path("reports")
|
196 |
+
|
197 |
+
if format_type == "markdown":
|
198 |
+
filepath = reports_dir / f"{report_filename}.md"
|
199 |
+
elif format_type == "html":
|
200 |
+
filepath = reports_dir / f"{report_filename}.html"
|
201 |
+
elif format_type == "pdf":
|
202 |
+
filepath = reports_dir / f"{report_filename}.pdf"
|
203 |
+
|
204 |
+
# Створення генератора звітів
|
205 |
+
report_generator = ReportGenerator(
|
206 |
+
self.current_data,
|
207 |
+
self.current_analysis.get("stats"),
|
208 |
+
self.current_analysis.get("inactive_issues"),
|
209 |
+
self.current_analysis.get("ai_analysis")
|
210 |
+
)
|
211 |
+
|
212 |
+
# Збереження звіту
|
213 |
+
saved_path = report_generator.save_report(
|
214 |
+
filepath=filepath,
|
215 |
+
format=format_type,
|
216 |
+
include_visualizations=include_visualizations,
|
217 |
+
visualization_data=self.visualizations if include_visualizations else None
|
218 |
+
)
|
219 |
+
|
220 |
+
if saved_path:
|
221 |
+
return f"Звіт успішно збережено: {saved_path}"
|
222 |
+
else:
|
223 |
+
return "Не вдалося зберегти звіт"
|
224 |
+
|
225 |
+
except Exception as e:
|
226 |
+
error_msg = f"Помилка при збереженні звіту: {str(e)}\n\n{traceback.format_exc()}"
|
227 |
+
logger.error(error_msg)
|
228 |
+
return error_msg
|
229 |
+
|
230 |
+
def test_jira_connection(self, jira_url, username, api_token):
|
231 |
+
"""
|
232 |
+
Тестування підключення до Jira
|
233 |
+
|
234 |
+
Args:
|
235 |
+
jira_url (str): URL сервера Jira
|
236 |
+
username (str): Ім'я користувача
|
237 |
+
api_token (str): API токен
|
238 |
+
|
239 |
+
Returns:
|
240 |
+
bool: True якщо підключення успішне, False інакше
|
241 |
+
"""
|
242 |
+
from modules.data_import.jira_api import JiraConnector
|
243 |
+
return JiraConnector.test_connection(jira_url, username, api_token)
|
244 |
+
|
245 |
+
def generate_visualization(self, viz_type, limit=10, groupby="day"):
|
246 |
+
"""
|
247 |
+
Генерація конкретної візуалізації
|
248 |
+
|
249 |
+
Args:
|
250 |
+
viz_type (str): Тип візуалізації
|
251 |
+
limit (int): Ліміт для топ-N елементів
|
252 |
+
groupby (str): Групування для часових діаграм ('day', 'week', 'month')
|
253 |
+
|
254 |
+
Returns:
|
255 |
+
matplotlib.figure.Figure: Об'єкт figure
|
256 |
+
"""
|
257 |
+
if self.current_data is None:
|
258 |
+
logger.error("Немає даних для візуалізації")
|
259 |
+
return None
|
260 |
+
|
261 |
+
# Створюємо візуалізатор
|
262 |
+
visualizer = JiraVisualizer(self.current_data)
|
263 |
+
|
264 |
+
# Вибір типу візуалізації
|
265 |
+
if viz_type == "Статуси":
|
266 |
+
return visualizer.plot_status_counts()
|
267 |
+
elif viz_type == "Пріоритети":
|
268 |
+
return visualizer.plot_priority_counts()
|
269 |
+
elif viz_type == "Типи тікетів":
|
270 |
+
return visualizer.plot_type_counts()
|
271 |
+
elif viz_type == "Призначені користувачі":
|
272 |
+
return visualizer.plot_assignee_counts(limit=limit)
|
273 |
+
elif viz_type == "Активність створення":
|
274 |
+
return visualizer.plot_timeline(date_column='Created', groupby=groupby, cumulative=False)
|
275 |
+
elif viz_type == "Активність оновлення":
|
276 |
+
return visualizer.plot_timeline(date_column='Updated', groupby=groupby, cumulative=False)
|
277 |
+
elif viz_type == "Кумулятивне створення":
|
278 |
+
return visualizer.plot_timeline(date_column='Created', groupby=groupby, cumulative=True)
|
279 |
+
elif viz_type == "Неактивні тікети":
|
280 |
+
return visualizer.plot_inactive_issues()
|
281 |
+
elif viz_type == "Теплова карта: Типи/Статуси":
|
282 |
+
return visualizer.plot_heatmap(row_col='Issue Type', column_col='Status')
|
283 |
+
elif viz_type == "Часова шкала проекту":
|
284 |
+
timeline_plots = visualizer.plot_project_timeline()
|
285 |
+
return timeline_plots[0] if timeline_plots[0] is not None else None
|
286 |
+
elif viz_type == "Склад статусів з часом":
|
287 |
+
timeline_plots = visualizer.plot_project_timeline()
|
288 |
+
return timeline_plots[1] if timeline_plots[1] is not None else None
|
289 |
+
else:
|
290 |
+
logger.error(f"Невідомий тип візуалізації: {viz_type}")
|
291 |
+
return None
|
292 |
+
|
293 |
+
def generate_infographic(self):
|
294 |
+
"""
|
295 |
+
Генерація інфографіки з основними показниками
|
296 |
+
|
297 |
+
Returns:
|
298 |
+
matplotlib.figure.Figure: Об'єкт figure з інфографікою
|
299 |
+
"""
|
300 |
+
if self.current_data is None or self.current_analysis is None:
|
301 |
+
logger.error("Немає даних для створення інфографіки")
|
302 |
+
return None
|
303 |
+
|
304 |
+
visualizer = JiraVisualizer(self.current_data)
|
305 |
+
return visualizer.create_infographic(self.current_analysis["stats"])
|
306 |
+
|
307 |
+
def generate_ai_report(self, api_key, model_type="gemini", temperature=0.2, custom_prompt=None):
|
308 |
+
"""
|
309 |
+
Генерація AI-звіту на основі даних
|
310 |
+
|
311 |
+
Args:
|
312 |
+
api_key (str): API ключ для LLM
|
313 |
+
model_type (str): Тип моделі ("openai" або "gemini")
|
314 |
+
temperature (float): Температура генерації
|
315 |
+
custom_prompt (str): Користувацький промпт
|
316 |
+
|
317 |
+
Returns:
|
318 |
+
str: Згенерований звіт або повідомлення про помилку
|
319 |
+
"""
|
320 |
+
try:
|
321 |
+
if self.current_data is None or self.current_analysis is None:
|
322 |
+
return "Помилка: спочатку виконайте аналіз даних"
|
323 |
+
|
324 |
+
# Перевіряємо наявність індексів
|
325 |
+
indices_dir = getattr(self, "current_indices_dir", None)
|
326 |
+
|
327 |
+
# Якщо індекси не створені, створюємо їх
|
328 |
+
if not indices_dir:
|
329 |
+
logger.info("Індекси не знайдено. Створюємо нові індекси.")
|
330 |
+
indices_result = self.index_manager.get_or_create_indices(
|
331 |
+
self.current_data,
|
332 |
+
self.current_session_id or f"temp_{uuid.uuid4()}"
|
333 |
+
)
|
334 |
+
|
335 |
+
if "error" in indices_result:
|
336 |
+
logger.error(f"Помилка при створенні індексів: {indices_result['error']}")
|
337 |
+
return f"Помилка при створенні індексів: {indices_result['error']}"
|
338 |
+
|
339 |
+
indices_dir = indices_result["indices_dir"]
|
340 |
+
self.current_indices_dir = indices_dir
|
341 |
+
|
342 |
+
# Імпортуємо AI асистента
|
343 |
+
JiraHybridChat
|
344 |
+
|
345 |
+
|
346 |
+
# Створюємо AI асистента
|
347 |
+
ai_assistant = JiraHybridChat(
|
348 |
+
api_key_openai=api_key if model_type == "openai" else None,
|
349 |
+
api_key_gemini=api_key if model_type == "gemini" else None,
|
350 |
+
model_type=model_type, temperature=temperature
|
351 |
+
)
|
352 |
+
|
353 |
+
# Генеруємо звіт
|
354 |
+
report_result = ai_assistant.generate_report(
|
355 |
+
self.current_data,
|
356 |
+
indices_dir=indices_dir,
|
357 |
+
custom_prompt=custom_prompt
|
358 |
+
)
|
359 |
+
|
360 |
+
if "error" in report_result:
|
361 |
+
logger.error(f"Помилка при генерації AI-звіту: {report_result['error']}")
|
362 |
+
return f"Помилка при генерації AI-звіту: {report_result['error']}"
|
363 |
+
|
364 |
+
# Зберігаємо звіт
|
365 |
+
report_path = Path("reports") / f"ai_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md"
|
366 |
+
with open(report_path, "w", encoding="utf-8") as f:
|
367 |
+
f.write(report_result["report"])
|
368 |
+
|
369 |
+
logger.info(f"AI-звіт успішно згенеровано та збережено: {report_path}")
|
370 |
+
|
371 |
+
return report_result["report"]
|
372 |
+
|
373 |
+
except Exception as e:
|
374 |
+
error_msg = f"Помилка при генерації AI-звіту: {str(e)}\n\n{traceback.format_exc()}"
|
375 |
+
logger.error(error_msg)
|
376 |
+
return error_msg
|
377 |
+
|
378 |
+
def chat_with_data(self, question, api_key, model_type="gemini", temperature=0.2, chat_history=None):
|
379 |
+
"""
|
380 |
+
Чат з даними через AI
|
381 |
+
|
382 |
+
Args:
|
383 |
+
question (str): Питання користувача
|
384 |
+
api_key (str): API ключ для LLM
|
385 |
+
model_type (str): Тип моделі ("openai" або "gemini")
|
386 |
+
temperature (float): Температура генерації
|
387 |
+
chat_history (list): Історія чату
|
388 |
+
|
389 |
+
Returns:
|
390 |
+
dict: Відповідь AI та метадані
|
391 |
+
"""
|
392 |
+
try:
|
393 |
+
if self.current_data is None:
|
394 |
+
return {"error": "Помилка: спочатку виконайте аналіз даних"}
|
395 |
+
|
396 |
+
# Перевіряємо наявність індексів
|
397 |
+
indices_dir = getattr(self, "current_indices_dir", None)
|
398 |
+
|
399 |
+
|
400 |
+
ai_assistant = JiraHybridChat(
|
401 |
+
indices_dir=indices_dir, # Передаємо індексну директорію
|
402 |
+
app=self, # Передаємо посилання на app
|
403 |
+
api_key_openai=api_key if model_type == "openai" else None,
|
404 |
+
api_key_gemini=api_key if model_type == "gemini" else None,
|
405 |
+
model_type=model_type,
|
406 |
+
temperature=temperature
|
407 |
+
)
|
408 |
+
|
409 |
+
ai_assistant.df = self.current_data
|
410 |
+
|
411 |
+
# Виконуємо чат
|
412 |
+
chat_result = ai_assistant.chat_with_hybrid_search(question, chat_history)
|
413 |
+
|
414 |
+
if "error" in chat_result:
|
415 |
+
logger.error(f"Помилка при виконанні чату: {chat_result['error']}")
|
416 |
+
return {"error": f"Помилка при виконанні чату: {chat_result['error']}"}
|
417 |
+
|
418 |
+
logger.info(f"Чат успішн�� виконано, токенів: {chat_result['metadata']['total_tokens']}")
|
419 |
+
|
420 |
+
return chat_result
|
421 |
+
|
422 |
+
except Exception as e:
|
423 |
+
error_msg = f"Помилка при виконанні чату: {str(e)}\n\n{traceback.format_exc()}"
|
424 |
+
logger.error(error_msg)
|
425 |
+
return {"error": error_msg}
|
426 |
+
|
427 |
+
def get_data_statistics(self):
|
428 |
+
"""
|
429 |
+
Отримання статистики даних
|
430 |
+
|
431 |
+
Returns:
|
432 |
+
dict: Статистика даних
|
433 |
+
"""
|
434 |
+
if self.current_data is None or self.current_analysis is None:
|
435 |
+
return {"error": "Немає даних для отримання статистики"}
|
436 |
+
|
437 |
+
return self.current_analysis["stats"]
|
438 |
+
|
439 |
+
def get_inactive_issues(self):
|
440 |
+
"""
|
441 |
+
Отримання неактивних тікетів
|
442 |
+
|
443 |
+
Returns:
|
444 |
+
dict: Неактивні тікети
|
445 |
+
"""
|
446 |
+
if self.current_data is None or self.current_analysis is None:
|
447 |
+
return {"error": "Немає даних для отримання неактивних тікетів"}
|
448 |
+
|
449 |
+
return self.current_analysis["inactive_issues"]
|
450 |
+
|
451 |
+
def get_data_sample(self, rows=5):
|
452 |
+
"""
|
453 |
+
Отримання зразка даних
|
454 |
+
|
455 |
+
Args:
|
456 |
+
rows (int): Кількість рядків
|
457 |
+
|
458 |
+
Returns:
|
459 |
+
dict: Зразок даних
|
460 |
+
"""
|
461 |
+
if self.current_data is None:
|
462 |
+
return {"error": "Немає даних для отримання зразка"}
|
463 |
+
|
464 |
+
try:
|
465 |
+
sample = self.current_data.head(rows).to_dict(orient="records")
|
466 |
+
return {"sample": sample, "columns": list(self.current_data.columns)}
|
467 |
+
except Exception as e:
|
468 |
+
return {"error": f"Помилка при отриманні зразка даних: {str(e)}"}
|
469 |
+
|
470 |
+
def get_model_info(self, api_key, model_type="gemini"):
|
471 |
+
"""
|
472 |
+
Отримання інформації про модель
|
473 |
+
|
474 |
+
Args:
|
475 |
+
api_key (str): API ключ для LLM
|
476 |
+
model_type (str): Тип моделі ("openai" або "gemini")
|
477 |
+
|
478 |
+
Returns:
|
479 |
+
dict: Інформація про модель
|
480 |
+
"""
|
481 |
+
try:
|
482 |
+
ai_assistant = JiraHybridChat(
|
483 |
+
api_key_openai=api_key if model_type == "openai" else None,
|
484 |
+
api_key_gemini=api_key if model_type == "gemini" else None,
|
485 |
+
model_type=model_type
|
486 |
+
)
|
487 |
+
|
488 |
+
return ai_assistant.get_model_info()
|
489 |
+
|
490 |
+
except Exception as e:
|
491 |
+
error_msg = f"Помилка при отриманні інформації про модель: {str(e)}"
|
492 |
+
logger.error(error_msg)
|
493 |
+
return {"error": error_msg}
|
494 |
+
|
495 |
+
def check_api_keys(self, api_key_openai=None, api_key_gemini=None):
|
496 |
+
"""
|
497 |
+
Перевірка API ключів
|
498 |
+
|
499 |
+
Args:
|
500 |
+
api_key_openai (str): API ключ для OpenAI
|
501 |
+
api_key_gemini (str): API ключ для Gemini
|
502 |
+
|
503 |
+
Returns:
|
504 |
+
dict: Результати перевірки
|
505 |
+
"""
|
506 |
+
try:
|
507 |
+
|
508 |
+
ai_assistant = JiraHybridChat(
|
509 |
+
api_key_openai=api_key_openai,
|
510 |
+
api_key_gemini=api_key_gemini
|
511 |
+
)
|
512 |
+
|
513 |
+
return ai_assistant.check_api_keys()
|
514 |
+
|
515 |
+
except Exception as e:
|
516 |
+
error_msg = f"Помилка при перевірці API ключів: {str(e)}"
|
517 |
+
logger.error(error_msg)
|
518 |
+
return {"error": error_msg}
|
519 |
+
|
520 |
+
def cleanup_old_indices(self, max_age_days=7, max_indices=20):
|
521 |
+
"""
|
522 |
+
Очищення застарілих індексів
|
523 |
+
|
524 |
+
Args:
|
525 |
+
max_age_days (int): Максимальний вік індексів у днях
|
526 |
+
max_indices (int): Максимальна кількість індексів для зберігання
|
527 |
+
|
528 |
+
Returns:
|
529 |
+
dict: Результат очищення
|
530 |
+
"""
|
531 |
+
try:
|
532 |
+
deleted_count = self.index_manager.cleanup_old_indices(max_age_days, max_indices)
|
533 |
+
|
534 |
+
logger.info(f"Очищено {deleted_count} застарілих індексів")
|
535 |
+
|
536 |
+
return {
|
537 |
+
"success": True,
|
538 |
+
"deleted_count": deleted_count,
|
539 |
+
"message": f"Очищено {deleted_count} застарілих індексів"
|
540 |
+
}
|
541 |
+
|
542 |
+
except Exception as e:
|
543 |
+
error_msg = f"Помилка при очищенні застарілих індексів: {str(e)}"
|
544 |
+
logger.error(error_msg)
|
545 |
+
return {"error": error_msg}
|
modules/ai_analysis/ai_assistant.py
ADDED
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import logging
|
2 |
+
import traceback
|
3 |
+
import os
|
4 |
+
import json
|
5 |
+
from pathlib import Path
|
6 |
+
import time
|
7 |
+
from typing import Dict, List, Any, Optional, Tuple
|
8 |
+
|
9 |
+
# Імпорт необхідних модулів для роботи з індексами
|
10 |
+
from llama_index.core import (
|
11 |
+
StorageContext,
|
12 |
+
load_index_from_storage
|
13 |
+
)
|
14 |
+
from llama_index.retrievers.bm25 import BM25Retriever
|
15 |
+
from llama_index.core.query_engine import RetrieverQueryEngine
|
16 |
+
from llama_index.core.retrievers import QueryFusionRetriever
|
17 |
+
from llama_index.core.llms import ChatMessage
|
18 |
+
|
19 |
+
# Імпорт утиліт для роботи з індексами
|
20 |
+
from modules.data_management.index_utils import (
|
21 |
+
check_indexing_availability,
|
22 |
+
check_index_integrity,
|
23 |
+
count_tokens
|
24 |
+
)
|
25 |
+
|
26 |
+
# Імпорт налаштувань
|
27 |
+
from modules.config.ai_settings import (
|
28 |
+
SIMILARITY_TOP_K,
|
29 |
+
HYBRID_SEARCH_MODE
|
30 |
+
)
|
31 |
+
from prompts import system_prompt_hybrid_chat
|
32 |
+
|
33 |
+
# Налаштування логування
|
34 |
+
logger = logging.getLogger(__name__)
|
35 |
+
|
36 |
+
|
37 |
+
class JiraAIAssistant:
|
38 |
+
"""
|
39 |
+
Клас для роботи з AI асистентом для аналізу даних Jira.
|
40 |
+
"""
|
41 |
+
|
42 |
+
def __init__(self, indices_dir=None, model_name="gpt-3.5-turbo", temperature=0.7):
|
43 |
+
"""
|
44 |
+
Ініціалізація асистента.
|
45 |
+
|
46 |
+
Args:
|
47 |
+
indices_dir (str): Шлях до директорії з індексами
|
48 |
+
model_name (str): Назва моделі для використання
|
49 |
+
temperature (float): Температура для генерації
|
50 |
+
"""
|
51 |
+
self.indices_dir = indices_dir
|
52 |
+
self.model_name = model_name
|
53 |
+
self.temperature = temperature
|
54 |
+
self.index = None
|
55 |
+
self.bm25_retriever = None
|
56 |
+
|
57 |
+
# Завантажуємо індекси, якщо вказано шлях
|
58 |
+
if indices_dir:
|
59 |
+
self.load_indices(indices_dir)
|
60 |
+
|
61 |
+
def load_indices(self, indices_path):
|
62 |
+
"""
|
63 |
+
Завантаження індексів з директорії.
|
64 |
+
|
65 |
+
Args:
|
66 |
+
indices_path (str): Шлях до директорії з індексами
|
67 |
+
|
68 |
+
Returns:
|
69 |
+
bool: True, якщо індекси успішно завантажено
|
70 |
+
"""
|
71 |
+
try:
|
72 |
+
logger.info(f"Завантаження індексів з {indices_path}")
|
73 |
+
|
74 |
+
# Перевіряємо наявність директорії
|
75 |
+
if not os.path.exists(indices_path):
|
76 |
+
logger.error(f"Директорія з індексами не існує: {indices_path}")
|
77 |
+
return False
|
78 |
+
|
79 |
+
# Перевіряємо наявність файлу-маркера
|
80 |
+
marker_path = os.path.join(indices_path, "indices.valid")
|
81 |
+
if not os.path.exists(marker_path):
|
82 |
+
logger.error(f"Файл-маркер індексів не знайдено: {marker_path}")
|
83 |
+
return False
|
84 |
+
|
85 |
+
# Імпортуємо необхідні модулі
|
86 |
+
from llama_index.core import VectorStoreIndex, StorageContext
|
87 |
+
from llama_index.retrievers.bm25 import BM25Retriever
|
88 |
+
|
89 |
+
try:
|
90 |
+
# Завантажуємо індекс
|
91 |
+
storage_context = StorageContext.from_defaults(persist_dir=str(indices_path))
|
92 |
+
self.index = VectorStoreIndex.from_storage_context(storage_context)
|
93 |
+
|
94 |
+
# Завантажуємо BM25 retriever
|
95 |
+
docstore = storage_context.docstore
|
96 |
+
|
97 |
+
# Завантажуємо параметри BM25
|
98 |
+
bm25_dir = os.path.join(indices_path, "bm25")
|
99 |
+
bm25_params_path = os.path.join(bm25_dir, "params.json")
|
100 |
+
|
101 |
+
if os.path.exists(bm25_params_path):
|
102 |
+
with open(bm25_params_path, "r", encoding="utf-8") as f:
|
103 |
+
bm25_params = json.load(f)
|
104 |
+
|
105 |
+
similarity_top_k = bm25_params.get("similarity_top_k", 10)
|
106 |
+
else:
|
107 |
+
similarity_top_k = 10
|
108 |
+
|
109 |
+
self.bm25_retriever = BM25Retriever.from_defaults(
|
110 |
+
docstore=docstore,
|
111 |
+
similarity_top_k=similarity_top_k
|
112 |
+
)
|
113 |
+
|
114 |
+
logger.info(f"Індекси успішно завантажено з {indices_path}")
|
115 |
+
return True
|
116 |
+
|
117 |
+
except Exception as e:
|
118 |
+
logger.error(f"Помилка при завантаженні індексів: {e}")
|
119 |
+
logger.error(traceback.format_exc())
|
120 |
+
return False
|
121 |
+
|
122 |
+
except Exception as e:
|
123 |
+
logger.error(f"Помилка при завантаженні індексів: {e}")
|
124 |
+
logger.error(traceback.format_exc())
|
125 |
+
return False
|
126 |
+
|
127 |
+
def chat(self, query, history=None):
|
128 |
+
"""
|
129 |
+
Відповідь на запит користувача з використанням індексів.
|
130 |
+
|
131 |
+
Args:
|
132 |
+
query (str): Запит користувача
|
133 |
+
history (list, optional): Історія чату
|
134 |
+
|
135 |
+
Returns:
|
136 |
+
str: Відповідь асистента
|
137 |
+
"""
|
138 |
+
try:
|
139 |
+
if not self.index or not self.bm25_retriever:
|
140 |
+
return "Індекси не завантажено. Будь ласка, завантажте дані."
|
141 |
+
|
142 |
+
# Отримуємо відповідні документи
|
143 |
+
bm25_results = self.bm25_retriever.retrieve(query)
|
144 |
+
vector_results = self.index.as_retriever().retrieve(query)
|
145 |
+
|
146 |
+
# Об'єднуємо результати
|
147 |
+
all_results = list(bm25_results) + list(vector_results)
|
148 |
+
|
149 |
+
# Видаляємо дублікати
|
150 |
+
unique_results = []
|
151 |
+
seen_ids = set()
|
152 |
+
for result in all_results:
|
153 |
+
if result.node_id not in seen_ids:
|
154 |
+
unique_results.append(result)
|
155 |
+
seen_ids.add(result.node_id)
|
156 |
+
|
157 |
+
# Обмежуємо кількість результатів
|
158 |
+
unique_results = unique_results[:10]
|
159 |
+
|
160 |
+
# Формуємо контекст
|
161 |
+
context = "\n\n".join([result.get_content() for result in unique_results])
|
162 |
+
|
163 |
+
|
164 |
+
# Формуємо промпт
|
165 |
+
prompt = f"""Використовуй надану інформацію для відповіді на запитання.
|
166 |
+
|
167 |
+
Контекст:
|
168 |
+
{context}
|
169 |
+
|
170 |
+
Запитання: {query}
|
171 |
+
|
172 |
+
Дай детальну відповідь на запитання, використовуючи тільки інформацію з контексту. Якщо інформації недостатньо, скажи про це.
|
173 |
+
"""
|
174 |
+
|
175 |
+
# Отримуємо відповідь від моделі
|
176 |
+
from llama_index.llms.openai import OpenAI
|
177 |
+
|
178 |
+
llm = OpenAI(model=self.model_name, temperature=self.temperature)
|
179 |
+
response = llm.complete(prompt)
|
180 |
+
|
181 |
+
return response.text
|
182 |
+
|
183 |
+
except Exception as e:
|
184 |
+
logger.error(f"Помилка при обробці запиту: {e}")
|
185 |
+
logger.error(traceback.format_exc())
|
186 |
+
return f"Виникла помилка при обробці запиту: {str(e)}"
|
modules/ai_analysis/ai_assistant_integration_mod.py
ADDED
@@ -0,0 +1,838 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import logging
|
3 |
+
import gradio as gr
|
4 |
+
from pathlib import Path
|
5 |
+
import traceback
|
6 |
+
from datetime import datetime
|
7 |
+
import pandas as pd
|
8 |
+
import uuid
|
9 |
+
import json
|
10 |
+
from typing import Dict, List, Any, Optional, Tuple, Union
|
11 |
+
from modules.ai_analysis.jira_hybrid_chat import JiraHybridChat
|
12 |
+
|
13 |
+
from modules.config.ai_settings import (
|
14 |
+
SIMILARITY_TOP_K
|
15 |
+
)
|
16 |
+
|
17 |
+
|
18 |
+
# Налаштування логування
|
19 |
+
logger = logging.getLogger("jira_assistant_interface")
|
20 |
+
|
21 |
+
# Імпорт необхідних модулів
|
22 |
+
try:
|
23 |
+
from dotenv import load_dotenv
|
24 |
+
load_dotenv()
|
25 |
+
except ImportError:
|
26 |
+
logger.warning("Не вдалося імпортувати python-dotenv. Змінні середовища не будуть завантажені з .env файлу.")
|
27 |
+
|
28 |
+
try:
|
29 |
+
from modules.ai_analysis.jira_ai_report import JiraAIReport
|
30 |
+
REPORT_MODULE_AVAILABLE = True
|
31 |
+
logger.info("Успішно імпортовано JiraAIReport")
|
32 |
+
except ImportError:
|
33 |
+
REPORT_MODULE_AVAILABLE = False
|
34 |
+
logger.warning("Модуль JiraAIReport недоступний. Буде використано стандартний JiraAIAssistant для звітів.")
|
35 |
+
|
36 |
+
|
37 |
+
# Імпорт спеціалізованого Q/A асистента, якщо він доступний
|
38 |
+
try:
|
39 |
+
from modules.ai_analysis.jira_qa_assistant import JiraQAAssistant
|
40 |
+
QA_ASSISTANT_AVAILABLE = True
|
41 |
+
logger.info("Успішно імпортовано JiraQAAssistant")
|
42 |
+
except ImportError:
|
43 |
+
QA_ASSISTANT_AVAILABLE = False
|
44 |
+
logger.warning("Модуль JiraQAAssistant недоступний. Буде використано стандартний JiraAIAssistant для Q/A.")
|
45 |
+
|
46 |
+
# Імпорт LlamaIndex компонентів (перевірка чи доступні)
|
47 |
+
try:
|
48 |
+
from llama_index.core import Document, VectorStoreIndex, Settings
|
49 |
+
from llama_index.core.llms import ChatMessage
|
50 |
+
LLAMA_INDEX_AVAILABLE = True
|
51 |
+
except ImportError:
|
52 |
+
LLAMA_INDEX_AVAILABLE = False
|
53 |
+
logger.warning("LlamaIndex не доступний. Деякі функції можуть бути недоступні.")
|
54 |
+
|
55 |
+
# Допоміжні функції
|
56 |
+
def strip_assistant_prefix(text):
|
57 |
+
"""Видаляє префікс 'assistant:' з тексту відповіді"""
|
58 |
+
if isinstance(text, str) and text.startswith("assistant:"):
|
59 |
+
return text.replace("assistant:", "", 1).strip()
|
60 |
+
return text
|
61 |
+
|
62 |
+
def get_indices_dir(timestamp=None):
|
63 |
+
"""
|
64 |
+
Формує шлях до директорії для збереження індексів.
|
65 |
+
|
66 |
+
Args:
|
67 |
+
timestamp (str, optional): Часова мітка для унікальної ідентифікації.
|
68 |
+
Якщо None, буде створена автоматично.
|
69 |
+
|
70 |
+
Returns:
|
71 |
+
str: Шлях до директорії індексів
|
72 |
+
"""
|
73 |
+
# Якщо часова мітка не вказана, створюємо нову
|
74 |
+
if timestamp is None:
|
75 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
76 |
+
|
77 |
+
# Формуємо шлях до директорії
|
78 |
+
indices_dir = Path("temp") / "indices" / timestamp
|
79 |
+
|
80 |
+
# Створюємо директорію, якщо вона не існує
|
81 |
+
os.makedirs(indices_dir, exist_ok=True)
|
82 |
+
|
83 |
+
return str(indices_dir)
|
84 |
+
|
85 |
+
# Функції для роботи з індексами FAISS
|
86 |
+
def try_import_faiss_utils():
|
87 |
+
"""Імпортує FAISS утиліти, якщо вони доступні"""
|
88 |
+
try:
|
89 |
+
from modules.ai_analysis.faiss_utils import (
|
90 |
+
find_latest_indices,
|
91 |
+
find_indices_by_hash,
|
92 |
+
cleanup_old_indices,
|
93 |
+
generate_file_hash,
|
94 |
+
save_indices_metadata
|
95 |
+
)
|
96 |
+
return {
|
97 |
+
"find_latest_indices": find_latest_indices,
|
98 |
+
"find_indices_by_hash": find_indices_by_hash,
|
99 |
+
"cleanup_old_indices": cleanup_old_indices,
|
100 |
+
"generate_file_hash": generate_file_hash,
|
101 |
+
"save_indices_metadata": save_indices_metadata
|
102 |
+
}
|
103 |
+
except ImportError as faiss_err:
|
104 |
+
logger.warning(f"Не вдалося імпортувати FAISS утиліти: {faiss_err}. Будуть використані стандартні методи.")
|
105 |
+
return None
|
106 |
+
|
107 |
+
# Клас для управління сесіями
|
108 |
+
class UserSessionManager:
|
109 |
+
"""Управління сесіями користувачів"""
|
110 |
+
|
111 |
+
def __init__(self):
|
112 |
+
self.user_sessions = {}
|
113 |
+
|
114 |
+
def get_or_create_user_session(self, user_id=None):
|
115 |
+
"""
|
116 |
+
Отримує існуючу сесію або створює нову.
|
117 |
+
|
118 |
+
Args:
|
119 |
+
user_id (str, optional): ID користувача. Якщо не вказано, генерується випадковий.
|
120 |
+
|
121 |
+
Returns:
|
122 |
+
str: ID сесії
|
123 |
+
"""
|
124 |
+
# Якщо ID користувача не вказано, гене��уємо випадковий
|
125 |
+
if not user_id:
|
126 |
+
user_id = str(uuid.uuid4())
|
127 |
+
|
128 |
+
# Якщо сесія вже існує, повертаємо її
|
129 |
+
if user_id in self.user_sessions:
|
130 |
+
return self.user_sessions[user_id]
|
131 |
+
|
132 |
+
# Інакше створюємо нову сесію
|
133 |
+
session_id = f"{user_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
|
134 |
+
self.user_sessions[user_id] = {"session_id": session_id, "chat_history": []}
|
135 |
+
|
136 |
+
logger.info(f"Створено нову сесію {session_id} для користувача {user_id}")
|
137 |
+
return self.user_sessions[user_id]
|
138 |
+
|
139 |
+
def get_chat_history(self, user_id):
|
140 |
+
"""Отримує історію чату користувача"""
|
141 |
+
session = self.get_or_create_user_session(user_id)
|
142 |
+
return session.get("chat_history", [])
|
143 |
+
|
144 |
+
def update_chat_history(self, user_id, message, response):
|
145 |
+
"""Оновлює історію чату користувача"""
|
146 |
+
session = self.get_or_create_user_session(user_id)
|
147 |
+
|
148 |
+
if "chat_history" not in session:
|
149 |
+
session["chat_history"] = []
|
150 |
+
|
151 |
+
session["chat_history"].append({"role": "user", "content": message})
|
152 |
+
session["chat_history"].append({"role": "assistant", "content": response})
|
153 |
+
|
154 |
+
return session["chat_history"]
|
155 |
+
|
156 |
+
# Клас для інтеграції AI асистентів
|
157 |
+
class AIAssistantIntegration:
|
158 |
+
"""Інтеграція різних AI асистентів та інтерфейсу"""
|
159 |
+
|
160 |
+
def __init__(self, app):
|
161 |
+
"""
|
162 |
+
Ініціалізація інтеграції.
|
163 |
+
|
164 |
+
Args:
|
165 |
+
app: Екземпляр JiraAssistantApp
|
166 |
+
"""
|
167 |
+
self.app = app
|
168 |
+
self.session_manager = UserSessionManager()
|
169 |
+
|
170 |
+
# Отримуємо ключі API з .env
|
171 |
+
self.api_key_openai = os.getenv("OPENAI_API_KEY", "")
|
172 |
+
self.api_key_gemini = os.getenv("GEMINI_API_KEY", "")
|
173 |
+
|
174 |
+
# Імпортуємо FAISS утиліти, якщо доступні
|
175 |
+
self.faiss_utils = try_import_faiss_utils()
|
176 |
+
self.faiss_utils_available = self.faiss_utils is not None
|
177 |
+
|
178 |
+
if self.faiss_utils_available:
|
179 |
+
logger.info("FAISS утиліти успішно імпортовано")
|
180 |
+
|
181 |
+
def run_full_context_qa(self, question, model_type, temperature):
|
182 |
+
"""
|
183 |
+
Запускає режим Q/A з повним контекстом.
|
184 |
+
|
185 |
+
Args:
|
186 |
+
question (str): Питання користувача
|
187 |
+
model_type (str): Тип моделі
|
188 |
+
temperature (float): Температура генерації
|
189 |
+
|
190 |
+
Returns:
|
191 |
+
str: Відповідь на питання
|
192 |
+
"""
|
193 |
+
# Перевіряємо, чи є завантажений файл або дані
|
194 |
+
if (not hasattr(self.app, 'last_loaded_csv') or self.app.last_loaded_csv is None) and \
|
195 |
+
(not hasattr(self.app, 'current_data') or self.app.current_data is None):
|
196 |
+
return "Помилка: спочатку завантажте CSV файл у вкладці 'CSV Аналіз' або ініціалізуйте дані з локальних файлів"
|
197 |
+
|
198 |
+
if not question or question.strip() == "":
|
199 |
+
return "Будь ласка, введіть питання."
|
200 |
+
|
201 |
+
try:
|
202 |
+
# Перевіряємо доступність спеціалізованого Q/A асистента
|
203 |
+
if QA_ASSISTANT_AVAILABLE:
|
204 |
+
return self._run_qa_with_specialized_assistant(question, model_type, temperature)
|
205 |
+
else:
|
206 |
+
return self._run_qa_with_standard_assistant(question, model_type, temperature)
|
207 |
+
|
208 |
+
except Exception as e:
|
209 |
+
error_msg = f"Помилка при виконанні запиту: {str(e)}\n\n{traceback.format_exc()}"
|
210 |
+
logger.error(error_msg)
|
211 |
+
return error_msg
|
212 |
+
|
213 |
+
def _run_qa_with_specialized_assistant(self, question, model_type, temperature):
|
214 |
+
"""Виконує Q/A з використанням спеціалізованого асистента JiraQAAssistant"""
|
215 |
+
qa_assistant = JiraQAAssistant(
|
216 |
+
api_key_openai=self.api_key_openai,
|
217 |
+
api_key_gemini=self.api_key_gemini,
|
218 |
+
model_type=model_type,
|
219 |
+
temperature=float(temperature)
|
220 |
+
)
|
221 |
+
|
222 |
+
# Отримання даних для аналізу
|
223 |
+
if hasattr(self.app, 'current_data') and self.app.current_data is not None:
|
224 |
+
# Завантаження даних з DataFrame
|
225 |
+
logger.info("Використовуємо DataFrame з пам'яті для Q/A")
|
226 |
+
success = qa_assistant.load_documents_from_dataframe(self.app.current_data)
|
227 |
+
|
228 |
+
if not success:
|
229 |
+
return "Помилка: не вдалося завантажити дані з DataFrame"
|
230 |
+
else:
|
231 |
+
# Завантаження даних з файлу
|
232 |
+
temp_file_path = self.app.last_loaded_csv
|
233 |
+
if not os.path.exists(temp_file_path):
|
234 |
+
return f"Помилка: файл {temp_file_path} не знайдено"
|
235 |
+
|
236 |
+
# Зчитуємо DataFrame з файлу
|
237 |
+
df = pd.read_csv(temp_file_path)
|
238 |
+
success = qa_assistant.load_documents_from_dataframe(df)
|
239 |
+
|
240 |
+
if not success:
|
241 |
+
return "Помилка: не вдалося завантажити дані з CSV файлу"
|
242 |
+
|
243 |
+
# Виконуємо Q/A запит
|
244 |
+
result = qa_assistant.run_qa(question)
|
245 |
+
|
246 |
+
if "error" in result:
|
247 |
+
return f"Помилка: {result['error']}"
|
248 |
+
|
249 |
+
# Форматуємо відповідь з інформацією про токени
|
250 |
+
answer = result["answer"]
|
251 |
+
answer = strip_assistant_prefix(answer) # Видаляємо префікс "assistant:"
|
252 |
+
metadata = result["metadata"]
|
253 |
+
|
254 |
+
tokens_info = f"\n\n---\n*Використано токенів: питання={metadata['question_tokens']}, "
|
255 |
+
tokens_info += f"контекст={metadata['context_tokens']}, "
|
256 |
+
tokens_info += f"відповідь={metadata['response_tokens']}, "
|
257 |
+
tokens_info += f"всього={metadata['total_tokens']}*"
|
258 |
+
|
259 |
+
return answer + tokens_info
|
260 |
+
|
261 |
+
def _run_qa_with_standard_assistant(self, question, model_type, temperature):
|
262 |
+
"""Виконує Q/A з використанням стандартного асистента JiraAIAssistant"""
|
263 |
+
|
264 |
+
# Перевіряємо доступність індексів
|
265 |
+
indices_path = None
|
266 |
+
|
267 |
+
# 1. Спочатку перевіряємо шлях до індексів в додатку
|
268 |
+
if hasattr(self.app, 'indices_path') and self.app.indices_path and os.path.exists(self.app.indices_path):
|
269 |
+
indices_path = self.app.indices_path
|
270 |
+
logger.info(f"Використовуємо наявні індекси з app.indices_path: {indices_path}")
|
271 |
+
|
272 |
+
# 2. Перевіряємо індекси, пов'язані з сесією
|
273 |
+
elif hasattr(self.app, 'current_session_id') and self.app.current_session_id:
|
274 |
+
session_indices_dir = Path("temp/sessions") / self.app.current_session_id / "indices"
|
275 |
+
if session_indices_dir.exists():
|
276 |
+
indices_path = str(session_indices_dir)
|
277 |
+
logger.info(f"Використовуємо індекси сесії: {indices_path}")
|
278 |
+
# Зберігаємо шлях для майбутнього використання
|
279 |
+
self.app.indices_path = indices_path
|
280 |
+
|
281 |
+
# 3. Якщо є шлях до завантаженого файлу, шукаємо індекси для нього
|
282 |
+
elif hasattr(self.app, 'last_loaded_csv') and self.app.last_loaded_csv and os.path.exists(self.app.last_loaded_csv):
|
283 |
+
if self.faiss_utils_available:
|
284 |
+
try:
|
285 |
+
file_path = self.app.last_loaded_csv
|
286 |
+
csv_hash = self.faiss_utils["generate_file_hash"](file_path)
|
287 |
+
if csv_hash:
|
288 |
+
indices_exist, found_indices_path = self.faiss_utils["find_indices_by_hash"](csv_hash)
|
289 |
+
if indices_exist:
|
290 |
+
indices_path = found_indices_path
|
291 |
+
logger.info(f"Знайдено індекси за хешем CSV: {indices_path}")
|
292 |
+
# Зберігаємо шлях для майбутнього використання
|
293 |
+
self.app.indices_path = indices_path
|
294 |
+
except Exception as e:
|
295 |
+
logger.warning(f"Помилка при пошуку індексів за хешем: {e}")
|
296 |
+
|
297 |
+
# Підготовка об'єкту для кешування індексів, якщо його немає
|
298 |
+
if not hasattr(self, "_indices_cache"):
|
299 |
+
self._indices_cache = {}
|
300 |
+
|
301 |
+
# Пріоритет віддаємо кешованим індексам
|
302 |
+
assistant = None
|
303 |
+
if indices_path and indices_path in self._indices_cache:
|
304 |
+
# Використовуємо кешований асистент
|
305 |
+
assistant = self._indices_cache[indices_path]
|
306 |
+
|
307 |
+
# Оновлюємо параметри
|
308 |
+
assistant.model_type = model_type
|
309 |
+
assistant.temperature = float(temperature)
|
310 |
+
assistant._initialize_llm()
|
311 |
+
|
312 |
+
logger.info(f"Використовуємо кешований асистент для {indices_path}")
|
313 |
+
else:
|
314 |
+
# Створення нового асистента
|
315 |
+
assistant = JiraHybridChat(
|
316 |
+
api_key_openai=self.api_key_openai,
|
317 |
+
api_key_gemini=self.api_key_gemini,
|
318 |
+
model_type=model_type,
|
319 |
+
temperature=float(temperature)
|
320 |
+
)
|
321 |
+
|
322 |
+
# Спроба використання індексів
|
323 |
+
if indices_path and os.path.exists(indices_path):
|
324 |
+
# Завантажуємо індекси
|
325 |
+
logger.info(f"Спроба завантажити індекси з шляху: {indices_path}")
|
326 |
+
success = assistant.load_indices(indices_path)
|
327 |
+
|
328 |
+
if success and hasattr(assistant, 'index') and assistant.index is not None:
|
329 |
+
logger.info(f"Успішно завантажено індекси з {indices_path}")
|
330 |
+
|
331 |
+
# Також завантажуємо DataFrame для повної функціональності
|
332 |
+
if hasattr(self.app, 'current_data') and self.app.current_data is not None:
|
333 |
+
assistant.df = self.app.current_data
|
334 |
+
|
335 |
+
# Додаємо в кеш
|
336 |
+
self._indices_cache[indices_path] = assistant
|
337 |
+
logger.info(f"Додано асистента в кеш для {indices_path}")
|
338 |
+
else:
|
339 |
+
logger.warning(f"Не вдалося завантажити індекси з {indices_path}")
|
340 |
+
|
341 |
+
# Якщо не вдалося завантажити індекси, завантажуємо дані напряму
|
342 |
+
if not hasattr(assistant, 'index') or assistant.index is None:
|
343 |
+
if hasattr(self.app, 'current_data') and self.app.current_data is not None:
|
344 |
+
# Завантаження даних з DataFrame
|
345 |
+
logger.info("Використовуємо DataFrame з пам'яті")
|
346 |
+
success = assistant.load_data_from_dataframe(self.app.current_data)
|
347 |
+
|
348 |
+
if not success:
|
349 |
+
return "Помилка: не вдалося завантажити дані з DataFrame"
|
350 |
+
else:
|
351 |
+
# Завантаження даних з файлу
|
352 |
+
temp_file_path = self.app.last_loaded_csv
|
353 |
+
if not os.path.exists(temp_file_path):
|
354 |
+
return f"Помилка: файл {temp_file_path} не знайдено"
|
355 |
+
|
356 |
+
# Завантаження даних з CSV
|
357 |
+
success = assistant.load_data_from_csv(temp_file_path)
|
358 |
+
|
359 |
+
if not success:
|
360 |
+
return "Помилка: не вдалося завантажити дані з CSV-файлу. Перевірте формат файлу."
|
361 |
+
|
362 |
+
# Виконуємо запит
|
363 |
+
result = assistant.run_full_context_qa(question)
|
364 |
+
|
365 |
+
if "error" in result:
|
366 |
+
return f"Помилка: {result['error']}"
|
367 |
+
|
368 |
+
# Форматуємо відповідь з інформацією про токени
|
369 |
+
answer = result["answer"]
|
370 |
+
answer = strip_assistant_prefix(answer) # Видаляємо префікс "assistant:"
|
371 |
+
metadata = result["metadata"]
|
372 |
+
|
373 |
+
tokens_info = f"\n\n---\n*Використано токенів: питання={metadata['question_tokens']}, "
|
374 |
+
tokens_info += f"контекст={metadata['context_tokens']}, "
|
375 |
+
tokens_info += f"відповідь={metadata['response_tokens']}, "
|
376 |
+
tokens_info += f"всього={metadata['total_tokens']}*"
|
377 |
+
|
378 |
+
# Зберігаємо індекси, якщо вони створені і ще не збережені
|
379 |
+
if not indices_path and hasattr(assistant, 'index') and assistant.index is not None:
|
380 |
+
if self.faiss_utils_available and hasattr(self.app, 'last_loaded_csv'):
|
381 |
+
try:
|
382 |
+
file_path = self.app.last_loaded_csv
|
383 |
+
csv_hash = self.faiss_utils["generate_file_hash"](file_path)
|
384 |
+
|
385 |
+
if csv_hash and hasattr(assistant, 'save_indices'):
|
386 |
+
logger.info("Зберігаємо індекси для майбутнього використання")
|
387 |
+
new_indices_dir = get_indices_dir()
|
388 |
+
if assistant.save_indices(new_indices_dir):
|
389 |
+
# Збережемо метадані з хешем CSV
|
390 |
+
metadata_obj = {
|
391 |
+
"created_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
392 |
+
"csv_hash": csv_hash,
|
393 |
+
"document_count": len(assistant.jira_documents) if hasattr(assistant, 'jira_documents') else 0,
|
394 |
+
"storage_format": "binary" # Додаємо інформацію про формат зберігання
|
395 |
+
}
|
396 |
+
self.faiss_utils["save_indices_metadata"](new_indices_dir, metadata_obj)
|
397 |
+
logger.info(f"Індекси збережено у {new_indices_dir}")
|
398 |
+
|
399 |
+
# Зберігаємо шлях для майбутнього використання
|
400 |
+
self.app.indices_path = new_indices_dir
|
401 |
+
|
402 |
+
# Додаємо в кеш
|
403 |
+
self._indices_cache[new_indices_dir] = assistant
|
404 |
+
|
405 |
+
# Очистимо старі індекси
|
406 |
+
self.faiss_utils["cleanup_old_indices"](max_indices=3)
|
407 |
+
except Exception as save_err:
|
408 |
+
logger.warning(f"Помилка при збереженні індексів: {save_err}")
|
409 |
+
|
410 |
+
return answer + tokens_info
|
411 |
+
|
412 |
+
def process_chat_message(self, message, chat_history, model_type, temperature):
|
413 |
+
"""
|
414 |
+
Обробка повідомлення користувача в чаті.
|
415 |
+
|
416 |
+
Args:
|
417 |
+
message (str): Повідомлення користувача
|
418 |
+
chat_history (list): Історія чату у форматі Gradio
|
419 |
+
model_type (str): Тип моделі
|
420 |
+
temperature (float): Температура генерації
|
421 |
+
|
422 |
+
Returns:
|
423 |
+
tuple: (очищене поле вводу, оновлена історія чату)
|
424 |
+
"""
|
425 |
+
if not message or message.strip() == "":
|
426 |
+
return "", chat_history
|
427 |
+
|
428 |
+
# Перевіряємо, чи є завантажений файл або дані
|
429 |
+
if (not hasattr(self.app, 'last_loaded_csv') or self.app.last_loaded_csv is None) and \
|
430 |
+
(not hasattr(self.app, 'current_data') or self.app.current_data is None):
|
431 |
+
chat_history.append((message, "Помилка: спочатку завантажте CSV файл у вкладці 'CSV Аналіз' або ініціалізуйте дані з локальних файлів"))
|
432 |
+
return "", chat_history
|
433 |
+
|
434 |
+
try:
|
435 |
+
# Використовуємо jira_hybrid_chat для обробки запиту
|
436 |
+
from modules.ai_analysis.jira_hybrid_chat import JiraHybridChat
|
437 |
+
|
438 |
+
# Створюємо екземпляр чату з передачею app
|
439 |
+
chat = JiraHybridChat(
|
440 |
+
indices_dir=self.app.indices_path if hasattr(self.app, 'indices_path') else None,
|
441 |
+
app=self.app, # Передаємо app для доступу до current_data
|
442 |
+
api_key_openai=self.api_key_openai,
|
443 |
+
api_key_gemini=self.api_key_gemini,
|
444 |
+
model_type=model_type,
|
445 |
+
temperature=float(temperature)
|
446 |
+
)
|
447 |
+
|
448 |
+
# Конвертуємо історію чату у формат для асистента
|
449 |
+
formatted_history = []
|
450 |
+
for user_msg, ai_msg in chat_history:
|
451 |
+
formatted_history.append({"role": "user", "content": user_msg})
|
452 |
+
formatted_history.append({"role": "assistant", "content": ai_msg})
|
453 |
+
|
454 |
+
# Отримуємо відповідь
|
455 |
+
result = chat.chat_with_hybrid_search(message, formatted_history)
|
456 |
+
|
457 |
+
if "error" in result:
|
458 |
+
chat_history.append((message, f"Помилка: {result['error']}"))
|
459 |
+
return "", chat_history
|
460 |
+
|
461 |
+
# Форматуємо відповідь з інформацією про токени
|
462 |
+
answer = result["answer"]
|
463 |
+
metadata = result["metadata"]
|
464 |
+
|
465 |
+
# Додаємо інформацію про релевантні документи
|
466 |
+
docs_info = "\n\n*Релевантні документи:*\n"
|
467 |
+
if "relevant_documents" in metadata:
|
468 |
+
for doc in metadata['relevant_documents'][:SIMILARITY_TOP_K]: # Показуємо топ-3 документа
|
469 |
+
docs_info += f"*{doc.get('rank', '?')}.* [{doc.get('ticket_id', '?')}](https://jira.healthprecision.net/browse/{doc.get('ticket_id', '?')}) "
|
470 |
+
docs_info += f"(релевантність: {doc.get('relevance', 0):.4f}): {doc.get('summary', '')[:50]}...\n"
|
471 |
+
|
472 |
+
# Додаємо інформацію про токени
|
473 |
+
tokens_info = f"\n\n---\n*Використано токенів: питання={metadata.get('question_tokens', 0)}, "
|
474 |
+
tokens_info += f"контекст={metadata.get('context_tokens', 0)}, "
|
475 |
+
tokens_info += f"відповідь={metadata.get('response_tokens', 0)}, "
|
476 |
+
tokens_info += f"всього={metadata.get('total_tokens', 0)}*"
|
477 |
+
|
478 |
+
# Формуємо повну відповідь
|
479 |
+
full_answer = answer + docs_info + tokens_info
|
480 |
+
|
481 |
+
# Оновлюємо історію чату
|
482 |
+
chat_history.append((message, full_answer))
|
483 |
+
|
484 |
+
# Зберігаємо індекси, якщо вони створені і ще не збережені
|
485 |
+
if not hasattr(self.app, 'indices_path') and hasattr(chat, 'index') and chat.index is not None:
|
486 |
+
if hasattr(self, 'faiss_utils_available') and self.faiss_utils_available and hasattr(self.app, 'last_loaded_csv'):
|
487 |
+
try:
|
488 |
+
file_path = self.app.last_loaded_csv
|
489 |
+
csv_hash = self.faiss_utils["generate_file_hash"](file_path)
|
490 |
+
|
491 |
+
if csv_hash and hasattr(chat, 'save_indices'):
|
492 |
+
logger.info("Зберігаємо індекси для майбутнього використання")
|
493 |
+
new_indices_dir = get_indices_dir()
|
494 |
+
if chat.save_indices(new_indices_dir):
|
495 |
+
# Збережемо метадані з хешем CSV
|
496 |
+
metadata_obj = {
|
497 |
+
"created_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
498 |
+
"csv_hash": csv_hash,
|
499 |
+
"document_count": len(chat.jira_documents) if hasattr(chat, 'jira_documents') else 0,
|
500 |
+
"storage_format": "binary"
|
501 |
+
}
|
502 |
+
self.faiss_utils["save_indices_metadata"](new_indices_dir, metadata_obj)
|
503 |
+
logger.info(f"Індекси збережено у {new_indices_dir}")
|
504 |
+
|
505 |
+
# Зберігаємо шлях для майбутнього використання
|
506 |
+
self.app.indices_path = new_indices_dir
|
507 |
+
except Exception as save_err:
|
508 |
+
logger.warning(f"Помилка при збереженні індексів: {save_err}")
|
509 |
+
|
510 |
+
return "", chat_history
|
511 |
+
|
512 |
+
except Exception as e:
|
513 |
+
import traceback
|
514 |
+
error_msg = f"Помилка при обробці повідомлення: {str(e)}\n\n{traceback.format_exc()}"
|
515 |
+
logger.error(error_msg)
|
516 |
+
chat_history.append((message, f"Помилка: {str(e)}"))
|
517 |
+
return "", chat_history
|
518 |
+
|
519 |
+
def generate_ai_report(self, format_type, model_type, temperature):
|
520 |
+
"""
|
521 |
+
Генерація аналітичного звіту на основі даних Jira.
|
522 |
+
|
523 |
+
Args:
|
524 |
+
format_type (str): Формат звіту ("markdown", "html")
|
525 |
+
model_type (str): Тип моделі для використання
|
526 |
+
temperature (float): Температура для генерації
|
527 |
+
|
528 |
+
Returns:
|
529 |
+
str: Згенерований звіт або повідомлення про помилку
|
530 |
+
"""
|
531 |
+
try:
|
532 |
+
# Перевіряємо, чи є завантажений файл або дані
|
533 |
+
if (not hasattr(self.app, 'last_loaded_csv') or self.app.last_loaded_csv is None) and \
|
534 |
+
(not hasattr(self.app, 'current_data') or self.app.current_data is None):
|
535 |
+
return "Помилка: спочатку завантажте CSV файл у вкладці 'CSV Аналіз' або ініціалізуйте дані з локальних файлів"
|
536 |
+
|
537 |
+
# Використовуємо спеціалізований генератор звітів, якщо доступний
|
538 |
+
if REPORT_MODULE_AVAILABLE:
|
539 |
+
logger.info("Використовуємо спеціалізований модуль JiraAIReport для генерації звіту")
|
540 |
+
|
541 |
+
# Створення генератора звітів
|
542 |
+
report_generator = JiraAIReport(
|
543 |
+
api_key_openai=self.api_key_openai,
|
544 |
+
api_key_gemini=self.api_key_gemini,
|
545 |
+
model_type=model_type,
|
546 |
+
temperature=float(temperature)
|
547 |
+
)
|
548 |
+
|
549 |
+
# Завантаження даних
|
550 |
+
if hasattr(self.app, 'current_data') and self.app.current_data is not None:
|
551 |
+
# Завантаження даних з DataFrame
|
552 |
+
logger.info("Використовуємо DataFrame з пам'яті для генерації звіту")
|
553 |
+
success = report_generator.load_documents_from_dataframe(self.app.current_data)
|
554 |
+
|
555 |
+
if not success:
|
556 |
+
return "Помилка: не вдалося завантажити дані з DataFrame"
|
557 |
+
else:
|
558 |
+
# Завантаження даних з файлу
|
559 |
+
logger.info(f"Читаємо CSV файл: {self.app.last_loaded_csv}")
|
560 |
+
df = pd.read_csv(self.app.last_loaded_csv)
|
561 |
+
success = report_generator.load_documents_from_dataframe(df)
|
562 |
+
|
563 |
+
if not success:
|
564 |
+
return "Помилка: не вдалося завантажити дані з CSV файлу"
|
565 |
+
|
566 |
+
# Генерація звіту
|
567 |
+
result = report_generator.generate_report(format_type=format_type)
|
568 |
+
|
569 |
+
if "error" in result:
|
570 |
+
return f"Помилка: {result['error']}"
|
571 |
+
|
572 |
+
# Форматуємо відповідь з інформацією про токени
|
573 |
+
report = result["report"]
|
574 |
+
report = strip_assistant_prefix(report) # Видаляємо префікс "assistant:"
|
575 |
+
metadata = result["metadata"]
|
576 |
+
|
577 |
+
# Додаємо інформацію про токени
|
578 |
+
tokens_info = f"\n\n---\n*Використано токенів: контекст={metadata['context_tokens']}, "
|
579 |
+
tokens_info += f"звіт={metadata['report_tokens']}, "
|
580 |
+
tokens_info += f"всього={metadata['total_tokens']}, "
|
581 |
+
tokens_info += f"проаналізовано документів: {metadata['documents_used']}*"
|
582 |
+
|
583 |
+
if format_type.lower() == "markdown":
|
584 |
+
return report + tokens_info
|
585 |
+
else:
|
586 |
+
# Для HTML додаємо інформацію про токени внизу
|
587 |
+
tokens_html = f'<div style="margin-top: 20px; color: #666; font-size: 0.9em;">'
|
588 |
+
tokens_html += f'Використано токенів: контекст={metadata["context_tokens"]}, '
|
589 |
+
tokens_html += f'звіт={metadata["report_tokens"]}, '
|
590 |
+
tokens_html += f'всього={metadata["total_tokens"]}, '
|
591 |
+
tokens_html += f'проаналізовано документів: {metadata["documents_used"]}'
|
592 |
+
tokens_html += '</div>'
|
593 |
+
|
594 |
+
return report + tokens_html
|
595 |
+
else:
|
596 |
+
# Використовуємо стандартний механізм генерації звітів
|
597 |
+
logger.warning("Модуль JiraAIReport недоступний, використовуємо стандартний JiraAIAssistant")
|
598 |
+
|
599 |
+
# Створення асистента
|
600 |
+
assistant = JiraHybridChat(
|
601 |
+
api_key_openai=self.api_key_openai,
|
602 |
+
api_key_gemini=self.api_key_gemini,
|
603 |
+
model_type=model_type,
|
604 |
+
temperature=float(temperature)
|
605 |
+
)
|
606 |
+
|
607 |
+
# Завантаження даних
|
608 |
+
if hasattr(self.app, 'current_data') and self.app.current_data is not None:
|
609 |
+
# Завантаження даних з DataFrame
|
610 |
+
success = assistant.load_data_from_dataframe(self.app.current_data)
|
611 |
+
|
612 |
+
if not success:
|
613 |
+
return "Помилка: не вдалося завантажити дані з DataFrame"
|
614 |
+
else:
|
615 |
+
# Завантаження даних з файлу
|
616 |
+
success = assistant.load_data_from_csv(self.app.last_loaded_csv)
|
617 |
+
|
618 |
+
if not success:
|
619 |
+
return "Помилка: не вдалося завантажити дані з файлу"
|
620 |
+
|
621 |
+
# Отримуємо статистику для звіту
|
622 |
+
stats = assistant.get_statistics()
|
623 |
+
|
624 |
+
# Підготовка даних для звіту
|
625 |
+
data_summary = f"СТАТИСТИКА ПРОЕКТУ JIRA:\n\n"
|
626 |
+
data_summary += f"Загальна кількість тікетів: {stats['document_count']}\n\n"
|
627 |
+
|
628 |
+
data_summary += "Розподіл за статусами:\n"
|
629 |
+
for status, count in stats['status_counts'].items():
|
630 |
+
percentage = (count / stats['document_count'] * 100) if stats['document_count'] > 0 else 0
|
631 |
+
data_summary += f"- {status}: {count} ({percentage:.1f}%)\n"
|
632 |
+
|
633 |
+
data_summary += "\nРозподіл за типами:\n"
|
634 |
+
for type_name, count in stats['type_counts'].items():
|
635 |
+
percentage = (count / stats['document_count'] * 100) if stats['document_count'] > 0 else 0
|
636 |
+
data_summary += f"- {type_name}: {count} ({percentage:.1f}%)\n"
|
637 |
+
|
638 |
+
data_summary += "\nРозподіл за пріоритетами:\n"
|
639 |
+
for priority, count in stats['priority_counts'].items():
|
640 |
+
percentage = (count / stats['document_count'] * 100) if stats['document_count'] > 0 else 0
|
641 |
+
data_summary += f"- {priority}: {count} ({percentage:.1f}%)\n"
|
642 |
+
|
643 |
+
data_summary += "\nТоп виконавці завдань:\n"
|
644 |
+
for assignee, count in stats['top_assignees'].items():
|
645 |
+
data_summary += f"- {assignee}: {count} тікетів\n"
|
646 |
+
|
647 |
+
# Генерація звіту
|
648 |
+
result = assistant.generate_report(data_summary, format_type=format_type)
|
649 |
+
|
650 |
+
if "error" in result:
|
651 |
+
return f"Помилка: {result['error']}"
|
652 |
+
|
653 |
+
# Форматуємо відповідь з інформацією про токени
|
654 |
+
report = result["report"]
|
655 |
+
report = strip_assistant_prefix(report) # Видаляємо префікс "assistant:"
|
656 |
+
metadata = result["metadata"]
|
657 |
+
|
658 |
+
# Додаємо інформацію про токени
|
659 |
+
tokens_info = f"\n\n---\n*Використано токенів: контекст={metadata['context_tokens']}, "
|
660 |
+
tokens_info += f"звіт={metadata['report_tokens']}, "
|
661 |
+
tokens_info += f"всього={metadata['total_tokens']}*"
|
662 |
+
|
663 |
+
if format_type.lower() == "markdown":
|
664 |
+
return report + tokens_info
|
665 |
+
else:
|
666 |
+
# Для HTML додаємо інформацію про токени внизу
|
667 |
+
tokens_html = f'<div style="margin-top: 20px; color: #666; font-size: 0.9em;">'
|
668 |
+
tokens_html += f'Використано токенів: контекст={metadata["context_tokens"]}, '
|
669 |
+
tokens_html += f'звіт={metadata["report_tokens"]}, '
|
670 |
+
tokens_html += f'всього={metadata["total_tokens"]}'
|
671 |
+
tokens_html += '</div>'
|
672 |
+
|
673 |
+
return report + tokens_html
|
674 |
+
|
675 |
+
except Exception as e:
|
676 |
+
error_msg = f"Помилка при генерації звіту: {str(e)}\n\n{traceback.format_exc()}"
|
677 |
+
logger.error(error_msg)
|
678 |
+
return error_msg
|
679 |
+
|
680 |
+
def setup_ai_assistant_tab(app, interface):
|
681 |
+
"""
|
682 |
+
Налаштування вкладки AI асистентів з підтримкою Q/A, чату та звітів.
|
683 |
+
|
684 |
+
Args:
|
685 |
+
app: Екземпляр JiraAssistantApp
|
686 |
+
interface: Блок інтерфейсу Gradio
|
687 |
+
|
688 |
+
Returns:
|
689 |
+
bool: True якщо ініціалізація пройшла успішно
|
690 |
+
"""
|
691 |
+
try:
|
692 |
+
# Створюємо інтеграцію AI асистента
|
693 |
+
ai_integration = AIAssistantIntegration(app)
|
694 |
+
|
695 |
+
# Створюємо вкладку для AI асистентів
|
696 |
+
with gr.Tab("AI Асистенти"):
|
697 |
+
gr.Markdown("## AI Асистенти для Jira")
|
698 |
+
|
699 |
+
# Спільні параметри для всіх режимів (в один рядок)
|
700 |
+
with gr.Row():
|
701 |
+
model_type = gr.Dropdown(
|
702 |
+
choices=["gemini", "openai"],
|
703 |
+
value="gemini",
|
704 |
+
label="Модель LLM",
|
705 |
+
scale=1
|
706 |
+
)
|
707 |
+
temperature = gr.Slider(
|
708 |
+
minimum=0.0,
|
709 |
+
maximum=1.0,
|
710 |
+
value=0.2,
|
711 |
+
step=0.1,
|
712 |
+
label="Температура",
|
713 |
+
scale=2
|
714 |
+
)
|
715 |
+
|
716 |
+
# Інформація про необхідність завантажити файл
|
717 |
+
gr.Markdown("""
|
718 |
+
**❗ Примітка:** Для роботи AI асистентів спочатку завантажте CSV файл у вкладці "CSV Аналіз" або ініціалізуйте дані з локальних файлів
|
719 |
+
""")
|
720 |
+
|
721 |
+
# Розділяємо режими по вкладках
|
722 |
+
with gr.Tabs():
|
723 |
+
with gr.Tab("Q/A з повним контекстом"):
|
724 |
+
gr.Markdown("""
|
725 |
+
**У цьому режимі бот має доступ до всіх даних тікетів одночасно.**
|
726 |
+
|
727 |
+
Використовуйте цей режим для загальних питань про проект,
|
728 |
+
статистику, тренди та загальний аналіз.
|
729 |
+
""")
|
730 |
+
|
731 |
+
qa_question = gr.Textbox(
|
732 |
+
label="Ваше питання",
|
733 |
+
placeholder="Наприклад: Які тікети мають найвищий пріоритет?",
|
734 |
+
lines=3
|
735 |
+
)
|
736 |
+
qa_button = gr.Button("Отримати відповідь")
|
737 |
+
qa_answer = gr.Markdown(label="Відповідь")
|
738 |
+
|
739 |
+
# Прив'язуємо обробник
|
740 |
+
qa_button.click(
|
741 |
+
ai_integration.run_full_context_qa,
|
742 |
+
inputs=[qa_question, model_type, temperature],
|
743 |
+
outputs=[qa_answer]
|
744 |
+
)
|
745 |
+
|
746 |
+
with gr.Tab("Гібридний чат"):
|
747 |
+
gr.Markdown("""
|
748 |
+
**У цьому режимі бот використовує гібридний пошук (BM25 + векторний) для кращої якості результатів.**
|
749 |
+
|
750 |
+
Гібридний пошук поєднує переваги пошуку за ключовими словами та семантичного векторного пошуку.
|
751 |
+
Підходить для більшості запитів, забезпечуючи високу релевантність відповідей.
|
752 |
+
""")
|
753 |
+
|
754 |
+
# Використовуємо компонент Chatbot для історії повідомлень
|
755 |
+
chatbot = gr.Chatbot(
|
756 |
+
height=500,
|
757 |
+
avatar_images=["Human:", "AI:"]
|
758 |
+
)
|
759 |
+
|
760 |
+
# Поле для вводу повідомлення
|
761 |
+
msg = gr.Textbox(
|
762 |
+
placeholder="Після введення питанні натисність Shift+Enter",
|
763 |
+
lines=2,
|
764 |
+
show_label=False,
|
765 |
+
)
|
766 |
+
|
767 |
+
# Кнопка очищення історії
|
768 |
+
clear = gr.Button("Очистити історію")
|
769 |
+
|
770 |
+
# Прив'язуємо обробники
|
771 |
+
msg.submit(
|
772 |
+
ai_integration.process_chat_message,
|
773 |
+
inputs=[msg, chatbot, model_type, temperature],
|
774 |
+
outputs=[msg, chatbot]
|
775 |
+
)
|
776 |
+
|
777 |
+
# Функція для очищення історії чату
|
778 |
+
clear.click(lambda: [], None, chatbot, queue=False)
|
779 |
+
|
780 |
+
with gr.Tab("Генерація звіту"):
|
781 |
+
gr.Markdown("""
|
782 |
+
**Автоматична генерація аналітичного звіту на основі даних Jira.**
|
783 |
+
|
784 |
+
AI проаналізує дані CSV файлу та створить структурований звіт.
|
785 |
+
""")
|
786 |
+
|
787 |
+
with gr.Row():
|
788 |
+
format_type = gr.Radio(
|
789 |
+
choices=["markdown", "html"],
|
790 |
+
value="markdown",
|
791 |
+
label="Формат звіту"
|
792 |
+
)
|
793 |
+
|
794 |
+
report_button = gr.Button("Згенерувати звіт")
|
795 |
+
ai_report = gr.Markdown(label="Звіт", elem_id="ai_report_output")
|
796 |
+
|
797 |
+
# Додаємо CSS для стилізації звіту
|
798 |
+
gr.HTML("""
|
799 |
+
<style>
|
800 |
+
#ai_report_output {
|
801 |
+
height: 600px;
|
802 |
+
overflow-y: auto;
|
803 |
+
border: 1px solid #ddd;
|
804 |
+
padding: 20px;
|
805 |
+
border-radius: 4px;
|
806 |
+
background-color: #f9f9f9;
|
807 |
+
}
|
808 |
+
</style>
|
809 |
+
""")
|
810 |
+
|
811 |
+
# Прив'язуємо обробник
|
812 |
+
report_button.click(
|
813 |
+
ai_integration.generate_ai_report,
|
814 |
+
inputs=[format_type, model_type, temperature],
|
815 |
+
outputs=[ai_report]
|
816 |
+
)
|
817 |
+
|
818 |
+
return True
|
819 |
+
|
820 |
+
except ImportError as e:
|
821 |
+
logger.error(f"Помилка імпорту модулів для AI асистента: {e}")
|
822 |
+
|
823 |
+
# Якщо не вдалося імпортувати модулі, створюємо заглушку
|
824 |
+
with gr.Tab("AI Асистенти"):
|
825 |
+
gr.Markdown("## AI Асистенти для Jira")
|
826 |
+
gr.Markdown(f"""
|
827 |
+
### ⚠️ Потрібні додаткові залежності
|
828 |
+
|
829 |
+
Для роботи AI асистентів потрібно встановити додаткові бібліотеки:
|
830 |
+
|
831 |
+
```bash
|
832 |
+
pip install llama-index-llms-gemini llama-index llama-index-embeddings-openai llama-index-retrievers-bm25 llama-index-vector-stores-faiss faiss-cpu tiktoken
|
833 |
+
```
|
834 |
+
|
835 |
+
Помилка: {str(e)}
|
836 |
+
""")
|
837 |
+
|
838 |
+
return False
|
modules/ai_analysis/ai_assistant_methods.py
ADDED
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Методи для додавання до класу JiraAIAssistant
|
2 |
+
# Ці методи потрібно додати до існуючого файлу ai_assistant.py
|
3 |
+
|
4 |
+
def load_indices(self, indices_dir):
|
5 |
+
"""
|
6 |
+
Завантаження індексів з директорії.
|
7 |
+
|
8 |
+
Args:
|
9 |
+
indices_dir (str): Шлях до директорії з індексами
|
10 |
+
|
11 |
+
Returns:
|
12 |
+
bool: True, якщо індекси успішно завантажено, False інакше
|
13 |
+
"""
|
14 |
+
try:
|
15 |
+
from llama_index.core import load_index_from_storage
|
16 |
+
from llama_index.core.storage import StorageContext
|
17 |
+
|
18 |
+
logger.info(f"Завантаження індексів з директорії: {indices_dir}")
|
19 |
+
|
20 |
+
# Перевірка наявності директорії
|
21 |
+
if not os.path.exists(indices_dir):
|
22 |
+
logger.error(f"Директорія індексів не існує: {indices_dir}")
|
23 |
+
return False
|
24 |
+
|
25 |
+
# Перевірка наявності необхідних файлів
|
26 |
+
required_files = ["docstore.json"]
|
27 |
+
for file in required_files:
|
28 |
+
if not os.path.exists(os.path.join(indices_dir, file)):
|
29 |
+
logger.error(f"Відсутній необхідний файл: {file}")
|
30 |
+
return False
|
31 |
+
|
32 |
+
# Завантажуємо контекст зберігання
|
33 |
+
storage_context = StorageContext.from_defaults(persist_dir=indices_dir)
|
34 |
+
|
35 |
+
# Завантажуємо індекс
|
36 |
+
self.index = load_index_from_storage(storage_context)
|
37 |
+
|
38 |
+
# Отримуємо docstore з контексту зберігання
|
39 |
+
self.docstore = storage_context.docstore
|
40 |
+
|
41 |
+
# Отримуємо доступ до документів
|
42 |
+
node_dict = self.docstore.docs
|
43 |
+
self.nodes = list(node_dict.values())
|
44 |
+
|
45 |
+
# Створюємо BM25 retriever
|
46 |
+
self.retriever_bm25 = BM25Retriever.from_defaults(
|
47 |
+
docstore=self.docstore,
|
48 |
+
similarity_top_k=self.similarity_top_k
|
49 |
+
)
|
50 |
+
|
51 |
+
# Створюємо векторний retriever
|
52 |
+
self.retriever_vector = self.index.as_retriever(
|
53 |
+
similarity_top_k=self.similarity_top_k
|
54 |
+
)
|
55 |
+
|
56 |
+
# Створюємо гібридний retriever
|
57 |
+
self.retriever_fusion = QueryFusionRetriever(
|
58 |
+
[
|
59 |
+
self.retriever_bm25, # Пошук на основі BM25 (ключові слова)
|
60 |
+
self.retriever_vector, # Векторний пошук (семантичний)
|
61 |
+
],
|
62 |
+
mode="reciprocal_rerank", # Режим переранжування результатів
|
63 |
+
similarity_top_k=self.similarity_top_k,
|
64 |
+
num_queries=1, # Використовуємо тільки оригінальний запит
|
65 |
+
use_async=True, # Асинхронне виконання для швидкості
|
66 |
+
)
|
67 |
+
|
68 |
+
# Створюємо query engine на основі гібридного ретривера
|
69 |
+
self.query_engine = RetrieverQueryEngine(self.retriever_fusion)
|
70 |
+
|
71 |
+
# Відновлюємо jira_documents з вузлів
|
72 |
+
try:
|
73 |
+
self.jira_documents = []
|
74 |
+
for node in self.nodes:
|
75 |
+
# Створюємо документ з текстом та метаданими вузла
|
76 |
+
doc = Document(
|
77 |
+
text=node.text,
|
78 |
+
metadata=node.metadata
|
79 |
+
)
|
80 |
+
self.jira_documents.append(doc)
|
81 |
+
|
82 |
+
logger.info(f"Відновлено {len(self.jira_documents)} документів")
|
83 |
+
except Exception as e:
|
84 |
+
logger.warning(f"Не вдалося відновити jira_documents: {e}")
|
85 |
+
|
86 |
+
logger.info(f"Успішно завантажено індекси з {indices_dir}")
|
87 |
+
return True
|
88 |
+
|
89 |
+
except Exception as e:
|
90 |
+
logger.error(f"Помилка при завантаженні індексів: {e}")
|
91 |
+
return False
|
92 |
+
|
93 |
+
def save_indices(self, indices_dir):
|
94 |
+
"""
|
95 |
+
Збереження індексів у директорію.
|
96 |
+
|
97 |
+
Args:
|
98 |
+
indices_dir (str): Шлях до директорії для збереження індексів
|
99 |
+
|
100 |
+
Returns:
|
101 |
+
bool: True, якщо індекси успішно збережено, False інакше
|
102 |
+
"""
|
103 |
+
try:
|
104 |
+
logger.info(f"Збереження індексів у директорію: {indices_dir}")
|
105 |
+
|
106 |
+
# Перевірка наявності директорії
|
107 |
+
if not os.path.exists(indices_dir):
|
108 |
+
os.makedirs(indices_dir)
|
109 |
+
|
110 |
+
# Перевірка наявності індексу
|
111 |
+
if not hasattr(self, 'index') or self.index is None:
|
112 |
+
logger.error("В��дсутній індекс для збереження")
|
113 |
+
return False
|
114 |
+
|
115 |
+
# Збереження індексу
|
116 |
+
self.index.storage_context.persist(persist_dir=indices_dir)
|
117 |
+
|
118 |
+
# Збереження додаткових метаданих
|
119 |
+
try:
|
120 |
+
metadata = {
|
121 |
+
"created_at": datetime.now().isoformat(),
|
122 |
+
"documents_count": len(self.jira_documents) if hasattr(self, 'jira_documents') else 0,
|
123 |
+
"nodes_count": len(self.nodes) if hasattr(self, 'nodes') else 0,
|
124 |
+
"embedding_model": str(self.embed_model) if hasattr(self, 'embed_model') else "unknown"
|
125 |
+
}
|
126 |
+
|
127 |
+
with open(os.path.join(indices_dir, "metadata.json"), "w", encoding="utf-8") as f:
|
128 |
+
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
129 |
+
except Exception as meta_err:
|
130 |
+
logger.warning(f"Помилка при збереженні метаданих: {meta_err}")
|
131 |
+
|
132 |
+
logger.info(f"Індекси успішно збережено у {indices_dir}")
|
133 |
+
return True
|
134 |
+
|
135 |
+
except Exception as e:
|
136 |
+
logger.error(f"Помилка при збереженні індексів: {e}")
|
137 |
+
return False
|
modules/ai_analysis/faiss_utils.py
ADDED
@@ -0,0 +1,405 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Модуль утиліт для роботи з FAISS векторними індексами.
|
3 |
+
Цей файл повинен бути розміщений у modules/ai_analysis/faiss_utils.py
|
4 |
+
"""
|
5 |
+
|
6 |
+
import logging
|
7 |
+
import os
|
8 |
+
from pathlib import Path
|
9 |
+
import json
|
10 |
+
import hashlib
|
11 |
+
from datetime import datetime
|
12 |
+
import shutil
|
13 |
+
import tempfile
|
14 |
+
import sys
|
15 |
+
|
16 |
+
logger = logging.getLogger(__name__)
|
17 |
+
|
18 |
+
# Перевірка наявності змінних середовища Hugging Face
|
19 |
+
IS_HUGGINGFACE = os.environ.get("SPACE_ID") is not None
|
20 |
+
if IS_HUGGINGFACE:
|
21 |
+
logger.info("Виявлено середовище Hugging Face Spaces")
|
22 |
+
|
23 |
+
try:
|
24 |
+
import faiss
|
25 |
+
import numpy as np
|
26 |
+
from llama_index.vector_stores.faiss import FaissVectorStore
|
27 |
+
from llama_index.core import load_index_from_storage
|
28 |
+
from llama_index.core import StorageContext
|
29 |
+
|
30 |
+
FAISS_AVAILABLE = True
|
31 |
+
logger.info("FAISS успішно імпортовано")
|
32 |
+
except ImportError as e:
|
33 |
+
logger.warning(f"FAISS або llama-index-vector-stores-faiss не встановлено: {e}. Використання FAISS буде вимкнено.")
|
34 |
+
FAISS_AVAILABLE = False
|
35 |
+
|
36 |
+
def check_faiss_available():
|
37 |
+
"""
|
38 |
+
Перевірка доступності FAISS.
|
39 |
+
|
40 |
+
Returns:
|
41 |
+
bool: True, якщо FAISS доступний, False інакше
|
42 |
+
"""
|
43 |
+
return FAISS_AVAILABLE
|
44 |
+
|
45 |
+
def generate_file_hash(file_path):
|
46 |
+
"""
|
47 |
+
Генерує хеш для файлу на основі його вмісту.
|
48 |
+
|
49 |
+
Args:
|
50 |
+
file_path (str): Шлях до файлу
|
51 |
+
|
52 |
+
Returns:
|
53 |
+
str: Хеш файлу або None у випадку помилки
|
54 |
+
"""
|
55 |
+
try:
|
56 |
+
if not os.path.exists(file_path):
|
57 |
+
logger.error(f"Файл не знайдено: {file_path}")
|
58 |
+
return None
|
59 |
+
|
60 |
+
# Отримуємо базову інформацію про файл для додавання в хеш
|
61 |
+
file_stat = os.stat(file_path)
|
62 |
+
file_size = file_stat.st_size
|
63 |
+
file_mtime = file_stat.st_mtime
|
64 |
+
|
65 |
+
# Створюємо хеш на основі вмісту файлу
|
66 |
+
sha256 = hashlib.sha256()
|
67 |
+
|
68 |
+
# Додаємо базову інформацію про файл
|
69 |
+
sha256.update(f"{file_size}_{file_mtime}".encode())
|
70 |
+
|
71 |
+
# Додаємо вміст файлу
|
72 |
+
with open(file_path, "rb") as f:
|
73 |
+
for byte_block in iter(lambda: f.read(4096), b""):
|
74 |
+
sha256.update(byte_block)
|
75 |
+
|
76 |
+
return sha256.hexdigest()
|
77 |
+
|
78 |
+
except Exception as e:
|
79 |
+
logger.error(f"Помилка при генерації хешу файлу: {e}")
|
80 |
+
return None
|
81 |
+
|
82 |
+
def save_indices_metadata(directory_path, metadata):
|
83 |
+
"""
|
84 |
+
Зберігає метадані індексів у JSON файл.
|
85 |
+
|
86 |
+
Args:
|
87 |
+
directory_path (str): Шлях до директорії з індексами
|
88 |
+
metadata (dict): Метадані для збереження
|
89 |
+
|
90 |
+
Returns:
|
91 |
+
bool: True, якщо збереження успішне, False інакше
|
92 |
+
"""
|
93 |
+
try:
|
94 |
+
# Перевіряємо наявність директорії
|
95 |
+
if not os.path.exists(directory_path):
|
96 |
+
logger.warning(f"Директорія {directory_path} не існує. Створюємо...")
|
97 |
+
os.makedirs(directory_path, exist_ok=True)
|
98 |
+
|
99 |
+
metadata_path = Path(directory_path) / "metadata.json"
|
100 |
+
|
101 |
+
# Додаємо додаткову інформацію про оточення
|
102 |
+
metadata["environment"] = {
|
103 |
+
"is_huggingface": IS_HUGGINGFACE,
|
104 |
+
"python_version": sys.version,
|
105 |
+
"platform": sys.platform
|
106 |
+
}
|
107 |
+
|
108 |
+
# Додаємо логування для діагностики
|
109 |
+
logger.info(f"Збереження метаданих у {metadata_path}")
|
110 |
+
logger.info(f"Розмір метаданих: {len(str(metadata))} символів")
|
111 |
+
|
112 |
+
with open(metadata_path, "w", encoding="utf-8") as f:
|
113 |
+
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
114 |
+
|
115 |
+
# Перевіряємо, що файл було створено
|
116 |
+
if os.path.exists(metadata_path):
|
117 |
+
logger.info(f"Метадані успішно збережено у {metadata_path}")
|
118 |
+
return True
|
119 |
+
else:
|
120 |
+
logger.error(f"Файл {metadata_path} не було створено")
|
121 |
+
return False
|
122 |
+
|
123 |
+
except Exception as e:
|
124 |
+
logger.error(f"Помилка при збереженні метаданих: {e}")
|
125 |
+
return False
|
126 |
+
|
127 |
+
def load_indices_metadata(directory_path):
|
128 |
+
"""
|
129 |
+
Завантажує метадані індексів з JSON файлу.
|
130 |
+
|
131 |
+
Args:
|
132 |
+
directory_path (str): Шлях до директорії з індексами
|
133 |
+
|
134 |
+
Returns:
|
135 |
+
dict: Метадані або пустий словник у випадку помилки
|
136 |
+
"""
|
137 |
+
try:
|
138 |
+
metadata_path = Path(directory_path) / "metadata.json"
|
139 |
+
|
140 |
+
if not metadata_path.exists():
|
141 |
+
logger.warning(f"Файл метаданих не знайдено: {metadata_path}")
|
142 |
+
return {}
|
143 |
+
|
144 |
+
with open(metadata_path, "r", encoding="utf-8") as f:
|
145 |
+
metadata = json.load(f)
|
146 |
+
|
147 |
+
logger.info(f"Метадані успішно завантажено з {metadata_path}")
|
148 |
+
return metadata
|
149 |
+
|
150 |
+
except Exception as e:
|
151 |
+
logger.error(f"Помилка при завантаженні метаданих: {e}")
|
152 |
+
return {}
|
153 |
+
|
154 |
+
def find_latest_indices(base_dir="temp/indices"):
|
155 |
+
"""
|
156 |
+
Знаходить найновіші збережені індекси.
|
157 |
+
|
158 |
+
Args:
|
159 |
+
base_dir (str): Базова директорія з індексами
|
160 |
+
|
161 |
+
Returns:
|
162 |
+
tuple: (bool, str) - (наявність індексів, шлях до найновіших індексів)
|
163 |
+
"""
|
164 |
+
try:
|
165 |
+
# Перевіряємо наявність базової директорії
|
166 |
+
indices_dir = Path(base_dir)
|
167 |
+
|
168 |
+
if not indices_dir.exists():
|
169 |
+
logger.info(f"Директорія {base_dir} не існує")
|
170 |
+
return False, None
|
171 |
+
|
172 |
+
if not os.path.isdir(indices_dir):
|
173 |
+
logger.warning(f"{base_dir} існує, але не є директорією")
|
174 |
+
return False, None
|
175 |
+
|
176 |
+
if not any(indices_dir.iterdir()):
|
177 |
+
logger.info(f"Директорія {base_dir} порожня")
|
178 |
+
return False, None
|
179 |
+
|
180 |
+
# Отримання списку піддиректорій з індексами
|
181 |
+
try:
|
182 |
+
subdirs = [d for d in indices_dir.iterdir() if d.is_dir()]
|
183 |
+
except Exception as iter_err:
|
184 |
+
logger.error(f"Помилка при перегляді директорії {base_dir}: {iter_err}")
|
185 |
+
return False, None
|
186 |
+
|
187 |
+
if not subdirs:
|
188 |
+
logger.info("Індекси не знайдено")
|
189 |
+
return False, None
|
190 |
+
|
191 |
+
# Знаходимо найновішу директорію
|
192 |
+
try:
|
193 |
+
latest_dir = max(subdirs, key=lambda x: x.stat().st_mtime)
|
194 |
+
except Exception as sort_err:
|
195 |
+
logger.error(f"Помилка при сортуванні директорій: {sort_err}")
|
196 |
+
return False, None
|
197 |
+
|
198 |
+
logger.info(f"Знайдено індекси у директорії {latest_dir}")
|
199 |
+
return True, str(latest_dir)
|
200 |
+
|
201 |
+
except Exception as e:
|
202 |
+
logger.error(f"Помилка при пошуку індексів: {e}")
|
203 |
+
return False, None
|
204 |
+
|
205 |
+
def find_indices_by_hash(csv_hash, base_dir="temp/indices"):
|
206 |
+
"""
|
207 |
+
Знаходить індекси, що відповідають вказаному хешу CSV файлу.
|
208 |
+
|
209 |
+
Args:
|
210 |
+
csv_hash (str): Хеш CSV файлу
|
211 |
+
base_dir (str): Базова директорія з індексами
|
212 |
+
|
213 |
+
Returns:
|
214 |
+
tuple: (bool, str) - (наявність індексів, шлях до відповідних індексів)
|
215 |
+
"""
|
216 |
+
try:
|
217 |
+
if not csv_hash:
|
218 |
+
logger.warning("Не вказано хеш CSV файлу")
|
219 |
+
return False, None
|
220 |
+
|
221 |
+
# Перевіряємо наявність базової директорії
|
222 |
+
indices_dir = Path(base_dir)
|
223 |
+
|
224 |
+
if not indices_dir.exists():
|
225 |
+
logger.info(f"Директорія {base_dir} не існує")
|
226 |
+
return False, None
|
227 |
+
|
228 |
+
if not any(indices_dir.iterdir()):
|
229 |
+
logger.info(f"Директорія {base_dir} порожня")
|
230 |
+
return False, None
|
231 |
+
|
232 |
+
# Отримання списку піддиректорій з індексами
|
233 |
+
subdirs = [d for d in indices_dir.iterdir() if d.is_dir()]
|
234 |
+
if not subdirs:
|
235 |
+
logger.info("Індекси не знайдено")
|
236 |
+
return False, None
|
237 |
+
|
238 |
+
# Перевіряємо кожну директорію на відповідність хешу
|
239 |
+
for directory in subdirs:
|
240 |
+
metadata_path = directory / "metadata.json"
|
241 |
+
if metadata_path.exists():
|
242 |
+
try:
|
243 |
+
with open(metadata_path, "r", encoding="utf-8") as f:
|
244 |
+
metadata = json.load(f)
|
245 |
+
|
246 |
+
if "csv_hash" in metadata and metadata["csv_hash"] == csv_hash:
|
247 |
+
# Додатково перевіряємо наявність файлів індексів
|
248 |
+
if (directory / "docstore.json").exists():
|
249 |
+
logger.info(f"Знайдено індекси для CSV з хешем {csv_hash} у {directory}")
|
250 |
+
return True, str(directory)
|
251 |
+
else:
|
252 |
+
logger.warning(f"Знайдено метадані для CSV з хешем {csv_hash}, але файли індексів відсутні у {directory}")
|
253 |
+
except Exception as md_err:
|
254 |
+
logger.warning(f"Помилка при читанні метаданих {metadata_path}: {md_err}")
|
255 |
+
|
256 |
+
# Якщо відповідних індексів не знайдено, повертаємо найновіші
|
257 |
+
logger.info(f"Не знайдено індексів для CSV з хешем {csv_hash}, спроба знайти найновіші")
|
258 |
+
return find_latest_indices(base_dir)
|
259 |
+
|
260 |
+
except Exception as e:
|
261 |
+
logger.error(f"Помилка при пошуку індексів за хешем: {e}")
|
262 |
+
return False, None
|
263 |
+
|
264 |
+
def create_indices_directory(csv_hash=None, base_dir="temp/indices"):
|
265 |
+
"""
|
266 |
+
Створює директорію для зберігання індексів з часовою міткою.
|
267 |
+
|
268 |
+
Args:
|
269 |
+
csv_hash (str, optional): Хеш CSV файлу для метаданих
|
270 |
+
base_dir (str): Базова директорія для індексів
|
271 |
+
|
272 |
+
Returns:
|
273 |
+
str: Шлях до створеної директорії
|
274 |
+
"""
|
275 |
+
try:
|
276 |
+
# Створення базової директорії, якщо вона не існує
|
277 |
+
indices_dir = Path(base_dir)
|
278 |
+
|
279 |
+
# Очищаємо старі індекси перед створенням нових, якщо ми на Hugging Face
|
280 |
+
if IS_HUGGINGFACE and indices_dir.exists():
|
281 |
+
logger.info("Очищення старих індексів перед створенням нових на Hugging Face")
|
282 |
+
try:
|
283 |
+
# Видаляємо тільки старі директорії, якщо їх більше 1
|
284 |
+
subdirs = [d for d in indices_dir.iterdir() if d.is_dir()]
|
285 |
+
if len(subdirs) > 1:
|
286 |
+
# Сортуємо за часом модифікації (від найстаріших до найновіших)
|
287 |
+
sorted_dirs = sorted(subdirs, key=lambda x: x.stat().st_mtime)
|
288 |
+
|
289 |
+
# Залишаємо тільки найновішу директорію
|
290 |
+
for directory in sorted_dirs[:-1]:
|
291 |
+
try:
|
292 |
+
shutil.rmtree(directory)
|
293 |
+
logger.info(f"Видалено стару директорію: {directory}")
|
294 |
+
except Exception as del_err:
|
295 |
+
logger.warning(f"Не вдалося видалити директорію {directory}: {del_err}")
|
296 |
+
except Exception as clean_err:
|
297 |
+
logger.warning(f"Помилка при очищенні старих індексів: {clean_err}")
|
298 |
+
|
299 |
+
# Створюємо базову директорію
|
300 |
+
indices_dir.mkdir(exist_ok=True, parents=True)
|
301 |
+
|
302 |
+
# Створення унікальної директорії з часовою міткою
|
303 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
304 |
+
index_dir = indices_dir / timestamp
|
305 |
+
|
306 |
+
# Спроба створити директорію
|
307 |
+
try:
|
308 |
+
index_dir.mkdir(exist_ok=True)
|
309 |
+
except Exception as mkdir_err:
|
310 |
+
logger.error(f"Не вдалося створити директорію {index_dir}: {mkdir_err}")
|
311 |
+
|
312 |
+
# Створюємо тимчасову директорію як запасний варіант
|
313 |
+
try:
|
314 |
+
temp_dir = tempfile.mkdtemp(prefix="faiss_indices_")
|
315 |
+
logger.info(f"Створено тимчасову директорію: {temp_dir}")
|
316 |
+
return temp_dir
|
317 |
+
except Exception as temp_err:
|
318 |
+
logger.error(f"Не вдалося створити тимчасову директорію: {temp_err}")
|
319 |
+
return str(indices_dir / "fallback")
|
320 |
+
|
321 |
+
# Зберігаємо базові метадані
|
322 |
+
metadata = {
|
323 |
+
"created_at": timestamp,
|
324 |
+
"timestamp": datetime.now().timestamp(),
|
325 |
+
"csv_hash": csv_hash
|
326 |
+
}
|
327 |
+
|
328 |
+
save_indices_metadata(str(index_dir), metadata)
|
329 |
+
|
330 |
+
logger.info(f"Створено директорію для індексів: {index_dir}")
|
331 |
+
return str(index_dir)
|
332 |
+
|
333 |
+
except Exception as e:
|
334 |
+
logger.error(f"Помилка при створенні директорії індексів: {e}")
|
335 |
+
|
336 |
+
# Створюємо тимчасову директорію як запасний варіант
|
337 |
+
try:
|
338 |
+
temp_dir = tempfile.mkdtemp(prefix="faiss_indices_")
|
339 |
+
logger.info(f"Створено тимчасову д��ректорію для індексів: {temp_dir}")
|
340 |
+
return temp_dir
|
341 |
+
except Exception:
|
342 |
+
# Якщо і це не вдалося, використовуємо директорію temp
|
343 |
+
logger.error("Не вдалося створити навіть тимчасову директорію, використовуємо базову temp")
|
344 |
+
os.makedirs("temp", exist_ok=True)
|
345 |
+
return "temp"
|
346 |
+
|
347 |
+
def cleanup_old_indices(max_indices=3, base_dir="temp/indices"):
|
348 |
+
"""
|
349 |
+
Видаляє старі індекси, залишаючи тільки вказану кількість найновіших.
|
350 |
+
|
351 |
+
Args:
|
352 |
+
max_indices (int): Максимальна кількість індексів для зберігання
|
353 |
+
base_dir (str): Базова директорія з індексами
|
354 |
+
|
355 |
+
Returns:
|
356 |
+
int: Кількість видалених директорій
|
357 |
+
"""
|
358 |
+
try:
|
359 |
+
# На Hugging Face Space обмежуємо максимальну кількість індексів до 1
|
360 |
+
if IS_HUGGINGFACE:
|
361 |
+
max_indices = 1
|
362 |
+
logger.info("На Hugging Face Space обмежуємо кількість індексів до 1")
|
363 |
+
|
364 |
+
indices_dir = Path(base_dir)
|
365 |
+
|
366 |
+
if not indices_dir.exists():
|
367 |
+
logger.warning(f"Директорія {base_dir} не існує")
|
368 |
+
return 0
|
369 |
+
|
370 |
+
# Отримання списку піддиректорій з індексами
|
371 |
+
try:
|
372 |
+
subdirs = [d for d in indices_dir.iterdir() if d.is_dir()]
|
373 |
+
except Exception as iter_err:
|
374 |
+
logger.error(f"Помилка при скануванні директорії {base_dir}: {iter_err}")
|
375 |
+
return 0
|
376 |
+
|
377 |
+
if len(subdirs) <= max_indices:
|
378 |
+
logger.info(f"Кількість індексів ({len(subdirs)}) не перевищує ліміт ({max_indices})")
|
379 |
+
return 0
|
380 |
+
|
381 |
+
# Сортуємо директорії за часом модифікації (від найновіших до найстаріших)
|
382 |
+
try:
|
383 |
+
sorted_dirs = sorted(subdirs, key=lambda x: x.stat().st_mtime, reverse=True)
|
384 |
+
except Exception as sort_err:
|
385 |
+
logger.error(f"Помилка при сортуванні директорій: {sort_err}")
|
386 |
+
return 0
|
387 |
+
|
388 |
+
# Залишаємо тільки max_indices найновіших директорій
|
389 |
+
dirs_to_delete = sorted_dirs[max_indices:]
|
390 |
+
|
391 |
+
# Видаляємо старі директорії
|
392 |
+
deleted_count = 0
|
393 |
+
for directory in dirs_to_delete:
|
394 |
+
try:
|
395 |
+
shutil.rmtree(directory)
|
396 |
+
deleted_count += 1
|
397 |
+
logger.info(f"Видалено стару директорію індексів: {directory}")
|
398 |
+
except Exception as del_err:
|
399 |
+
logger.warning(f"Не вдалося видалити директорію {directory}: {del_err}")
|
400 |
+
|
401 |
+
return deleted_count
|
402 |
+
|
403 |
+
except Exception as e:
|
404 |
+
logger.error(f"Помилка при очищенні старих індексів: {e}")
|
405 |
+
return 0
|
modules/ai_analysis/google_embeddings_utils.py
ADDED
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Утиліти для роботи з Google Embeddings API.
|
3 |
+
Цей файл повинен бути розміщений у modules/ai_analysis/google_embeddings_utils.py
|
4 |
+
"""
|
5 |
+
|
6 |
+
import os
|
7 |
+
import logging
|
8 |
+
from typing import List, Dict, Any, Optional
|
9 |
+
import time
|
10 |
+
|
11 |
+
logger = logging.getLogger(__name__)
|
12 |
+
|
13 |
+
# Перевірка наявності необхідних бібліотек
|
14 |
+
try:
|
15 |
+
from google import genai
|
16 |
+
GOOGLE_GENAI_AVAILABLE = True
|
17 |
+
logger.info("Google GenAI SDK успішно імпортовано")
|
18 |
+
except ImportError as e:
|
19 |
+
logger.warning(f"Google GenAI SDK не встановлено: {e}. Використання Google Embeddings буде вимкнено.")
|
20 |
+
GOOGLE_GENAI_AVAILABLE = False
|
21 |
+
|
22 |
+
class GoogleEmbeddingsManager:
|
23 |
+
"""
|
24 |
+
Менеджер для роботи з Google Embeddings API.
|
25 |
+
"""
|
26 |
+
|
27 |
+
def __init__(self, api_key=None, model_name="text-embedding-004", task_type="retrieval_query"):
|
28 |
+
"""
|
29 |
+
Ініціалізація менеджера Google Embeddings.
|
30 |
+
|
31 |
+
Args:
|
32 |
+
api_key (str, optional): API ключ для Google API. Якщо не вказано, спробує використати GEMINI_API_KEY з середовища.
|
33 |
+
model_name (str): Назва моделі ембедингів.
|
34 |
+
task_type (str): Тип задачі для ембедингів. Може бути "retrieval_query" або "retrieval_document".
|
35 |
+
"""
|
36 |
+
self.api_key = api_key or os.getenv("GEMINI_API_KEY")
|
37 |
+
self.model_name = model_name
|
38 |
+
self.task_type = task_type
|
39 |
+
self.client = None
|
40 |
+
self.initialized = False
|
41 |
+
|
42 |
+
# Спроба ініціалізації клієнта
|
43 |
+
self._initialize_client()
|
44 |
+
|
45 |
+
def _initialize_client(self):
|
46 |
+
"""
|
47 |
+
Ініціалізує клієнт Google GenAI API.
|
48 |
+
|
49 |
+
Returns:
|
50 |
+
bool: True, якщо ініціалізація успішна, False в іншому випадку.
|
51 |
+
"""
|
52 |
+
if not GOOGLE_GENAI_AVAILABLE:
|
53 |
+
logger.error("Google GenAI SDK не встановлено. Встановіть пакет: pip install google-genai")
|
54 |
+
return False
|
55 |
+
|
56 |
+
if not self.api_key:
|
57 |
+
logger.error("API ключ для Google API не вказано. Встановіть змінну GEMINI_API_KEY.")
|
58 |
+
return False
|
59 |
+
|
60 |
+
try:
|
61 |
+
# Ініціалізація клієнта
|
62 |
+
genai.configure(api_key=self.api_key)
|
63 |
+
self.client = genai.Client()
|
64 |
+
self.initialized = True
|
65 |
+
logger.info(f"Клієнт Google GenAI успішно ініціалізовано для моделі {self.model_name}")
|
66 |
+
return True
|
67 |
+
except Exception as e:
|
68 |
+
logger.error(f"Помилка при ініціалізації клієнта Google GenAI: {e}")
|
69 |
+
return False
|
70 |
+
|
71 |
+
def get_embeddings(self, texts, batch_size=8, retry_attempts=3, retry_delay=1):
|
72 |
+
"""
|
73 |
+
Отримує ембединги для списку текстів.
|
74 |
+
|
75 |
+
Args:
|
76 |
+
texts (list): Список текстів для ембедингу.
|
77 |
+
batch_size (int): Розмір батча для обробки.
|
78 |
+
retry_attempts (int): Кількість спроб у випадку помилки.
|
79 |
+
retry_delay (int): Затримка між спробами в секундах.
|
80 |
+
|
81 |
+
Returns:
|
82 |
+
list: Список ембедингів для кожного тексту або None у випадку помилки.
|
83 |
+
"""
|
84 |
+
if not self.initialized:
|
85 |
+
if not self._initialize_client():
|
86 |
+
return None
|
87 |
+
|
88 |
+
if not texts:
|
89 |
+
logger.warning("Порожній список текстів для ембедингу")
|
90 |
+
return []
|
91 |
+
|
92 |
+
# Переконуємося, що input завжди список
|
93 |
+
if not isinstance(texts, list):
|
94 |
+
texts = [texts]
|
95 |
+
|
96 |
+
try:
|
97 |
+
all_embeddings = []
|
98 |
+
|
99 |
+
# Обробка по батчам для ефективності
|
100 |
+
for i in range(0, len(texts), batch_size):
|
101 |
+
batch = texts[i:i + batch_size]
|
102 |
+
|
103 |
+
# Спроби з повторами у випадку помилки
|
104 |
+
for attempt in range(retry_attempts):
|
105 |
+
try:
|
106 |
+
result = self.client.models.embed_content(
|
107 |
+
model=self.model_name,
|
108 |
+
contents=batch,
|
109 |
+
config={"task_type": self.task_type}
|
110 |
+
)
|
111 |
+
|
112 |
+
# Вилучення ембедингів
|
113 |
+
batch_embeddings = [embedding.values for embedding in result.embeddings]
|
114 |
+
all_embeddings.extend(batch_embeddings)
|
115 |
+
break
|
116 |
+
except Exception as e:
|
117 |
+
if attempt == retry_attempts - 1:
|
118 |
+
logger.error(f"Не вдалося отримати ембединги після {retry_attempts} спроб: {e}")
|
119 |
+
return None
|
120 |
+
logger.warning(f"Спроба {attempt+1} невдала: {e}. Повторна спроба через {retry_delay} сек.")
|
121 |
+
time.sleep(retry_delay)
|
122 |
+
|
123 |
+
logger.info(f"Успішно отримано {len(all_embeddings)} ембедингів від Google API")
|
124 |
+
return all_embeddings
|
125 |
+
|
126 |
+
except Exception as e:
|
127 |
+
logger.error(f"Помилка при отриманні ембедингів від Google API: {e}")
|
128 |
+
return None
|
129 |
+
|
130 |
+
def get_embedding_dimension(self):
|
131 |
+
"""
|
132 |
+
Отримує розмірність ембедингів.
|
133 |
+
|
134 |
+
Returns:
|
135 |
+
int: Розмірність ембедингів або 0 у випадку помилки.
|
136 |
+
"""
|
137 |
+
if not self.initialized:
|
138 |
+
if not self._initialize_client():
|
139 |
+
return 0
|
140 |
+
|
141 |
+
try:
|
142 |
+
# Отримуємо ембединг для тестового тексту
|
143 |
+
result = self.client.models.embed_content(
|
144 |
+
model=self.model_name,
|
145 |
+
contents=["Test"],
|
146 |
+
config={"task_type": self.task_type}
|
147 |
+
)
|
148 |
+
|
149 |
+
# Отримуємо розмірність
|
150 |
+
[embedding] = result.embeddings
|
151 |
+
return len(embedding.values)
|
152 |
+
|
153 |
+
except Exception as e:
|
154 |
+
logger.error(f"Помилка при отриманні розмірності ембедингів: {e}")
|
155 |
+
return 0
|
156 |
+
|
157 |
+
# Приклад використання
|
158 |
+
if __name__ == "__main__":
|
159 |
+
# Налаштування логування
|
160 |
+
logging.basicConfig(level=logging.INFO)
|
161 |
+
|
162 |
+
# Ініціалізація менеджера
|
163 |
+
manager = GoogleEmbeddingsManager()
|
164 |
+
|
165 |
+
# Отримання розмірності ембедингів
|
166 |
+
dimension = manager.get_embedding_dimension()
|
167 |
+
print(f"Розмірність ембедингів: {dimension}")
|
168 |
+
|
169 |
+
# Отримання ембедингів для текстів
|
170 |
+
texts = ["Це тестовий текст", "Це ще один тестовий текст"]
|
171 |
+
embeddings = manager.get_embeddings(texts)
|
172 |
+
|
173 |
+
if embeddings:
|
174 |
+
print(f"Отримано {len(embeddings)} ембедингів")
|
175 |
+
print(f"Розмірність першого ембедингу: {len(embeddings[0])}")
|
modules/ai_analysis/indices_initializer.py
ADDED
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import logging
|
2 |
+
import uuid
|
3 |
+
from datetime import datetime
|
4 |
+
import pandas as pd
|
5 |
+
|
6 |
+
logger = logging.getLogger(__name__)
|
7 |
+
|
8 |
+
def initialize_indices(app):
|
9 |
+
"""
|
10 |
+
Ініціалізує індекси BM25 та FAISS для режиму гібридного чату.
|
11 |
+
|
12 |
+
Args:
|
13 |
+
app: Екземпляр JiraAssistantApp
|
14 |
+
|
15 |
+
Returns:
|
16 |
+
dict: Результат ініціалізації
|
17 |
+
"""
|
18 |
+
if not hasattr(app, 'current_data') or app.current_data is None:
|
19 |
+
return {"error": "Немає даних для індексування. Спочатку завантажте CSV файл."}
|
20 |
+
|
21 |
+
try:
|
22 |
+
# Генеруємо унікальний ідентифікатор сесії, якщо він відсутній
|
23 |
+
if not hasattr(app, 'current_session_id') or app.current_session_id is None:
|
24 |
+
app.current_session_id = f"{uuid.uuid4()}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
25 |
+
logger.info(f"Створено новий ID сесії для індексування: {app.current_session_id}")
|
26 |
+
|
27 |
+
# Викликаємо метод для створення індексів
|
28 |
+
indices_result = app.index_manager.get_or_create_indices(
|
29 |
+
app.current_data,
|
30 |
+
app.current_session_id
|
31 |
+
)
|
32 |
+
|
33 |
+
if "error" in indices_result:
|
34 |
+
logger.error(f"Помилка при створенні індексів: {indices_result['error']}")
|
35 |
+
return indices_result
|
36 |
+
|
37 |
+
# Зберігаємо шлях до індексів у app
|
38 |
+
indices_dir = indices_result.get("indices_dir")
|
39 |
+
app.indices_path = indices_dir
|
40 |
+
app.current_indices_dir = indices_dir
|
41 |
+
|
42 |
+
logger.info(f"Індекси успішно створено: {indices_dir}")
|
43 |
+
|
44 |
+
# Зберігаємо шлях до індексів глобально для доступу з різних модулів
|
45 |
+
try:
|
46 |
+
import builtins
|
47 |
+
if hasattr(builtins, 'app'):
|
48 |
+
builtins.app.indices_path = indices_dir
|
49 |
+
logger.info(f"Шлях до індексів збережено глобально: {indices_dir}")
|
50 |
+
|
51 |
+
# Якщо також є глобальний index_manager, зберігаємо в ньому
|
52 |
+
if hasattr(builtins, 'index_manager'):
|
53 |
+
builtins.index_manager.last_indices_path = indices_dir
|
54 |
+
logger.info(f"Шлях до індексів збережено в index_manager: {indices_dir}")
|
55 |
+
except Exception as app_err:
|
56 |
+
logger.warning(f"Не вдалося зберегти шлях до індексів глобально: {app_err}")
|
57 |
+
|
58 |
+
# Очищаємо кеш екземплярів чату, якщо він є
|
59 |
+
if hasattr(app, 'chat_instances_cache'):
|
60 |
+
app.chat_instances_cache = {}
|
61 |
+
logger.info("Скинуто кеш екземплярів чату")
|
62 |
+
|
63 |
+
# Якщо є клас JiraHybridChat зі статичним кешем, очищаємо його
|
64 |
+
try:
|
65 |
+
from modules.ai_analysis.jira_hybrid_chat import JiraHybridChat
|
66 |
+
if hasattr(JiraHybridChat, 'chat_instances_cache'):
|
67 |
+
JiraHybridChat.chat_instances_cache = {}
|
68 |
+
logger.info("Скинуто статичний кеш JiraHybridChat")
|
69 |
+
except ImportError:
|
70 |
+
pass
|
71 |
+
|
72 |
+
return {
|
73 |
+
"success": True,
|
74 |
+
"indices_dir": indices_dir,
|
75 |
+
"documents_count": indices_result.get("documents_count", indices_result.get("rows_count", len(app.current_data))),
|
76 |
+
"nodes_count": indices_result.get("nodes_count", indices_result.get("rows_count", len(app.current_data)))
|
77 |
+
}
|
78 |
+
|
79 |
+
except Exception as e:
|
80 |
+
import traceback
|
81 |
+
error_details = traceback.format_exc()
|
82 |
+
logger.error(f"Помилка при ініціалізації індексів: {e}\n{error_details}")
|
83 |
+
return {"error": f"Помилка при ініціалізації індексів: {str(e)}"}
|
modules/ai_analysis/jira_ai_report.py
ADDED
@@ -0,0 +1,398 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import logging
|
3 |
+
import tiktoken
|
4 |
+
import pandas as pd
|
5 |
+
from datetime import datetime
|
6 |
+
from typing import List, Dict, Any, Optional
|
7 |
+
|
8 |
+
# Налаштування логування
|
9 |
+
logger = logging.getLogger(__name__)
|
10 |
+
|
11 |
+
# Перевірка наявності LlamaIndex
|
12 |
+
try:
|
13 |
+
from llama_index.core import Document
|
14 |
+
from llama_index.core.llms import ChatMessage
|
15 |
+
LLAMA_INDEX_AVAILABLE = True
|
16 |
+
except ImportError:
|
17 |
+
logger.warning("Не вдалося імпортувати LlamaIndex. Встановіть необхідні залежності для використання AI Report.")
|
18 |
+
LLAMA_INDEX_AVAILABLE = False
|
19 |
+
|
20 |
+
# Імпорт промпта для звіту
|
21 |
+
from prompts import get_report_prompt
|
22 |
+
|
23 |
+
class JiraAIReport:
|
24 |
+
"""
|
25 |
+
Клас для генерації аналітичних звітів на основі даних Jira,
|
26 |
+
використовуючи повний контекст даних (аналогічно режиму Q/A).
|
27 |
+
"""
|
28 |
+
|
29 |
+
def __init__(self, api_key_openai=None, api_key_gemini=None, model_type="gemini", temperature=0.2):
|
30 |
+
"""
|
31 |
+
Ініціалізація AI генератора звітів.
|
32 |
+
|
33 |
+
Args:
|
34 |
+
api_key_openai (str): API ключ для OpenAI
|
35 |
+
api_key_gemini (str): API ключ для Google Gemini
|
36 |
+
model_type (str): Тип моделі ("openai" або "gemini")
|
37 |
+
temperature (float): Параметр температури для генерації відповідей
|
38 |
+
"""
|
39 |
+
self.model_type = model_type.lower()
|
40 |
+
self.temperature = temperature
|
41 |
+
self.api_key_openai = api_key_openai or os.getenv("OPENAI_API_KEY", "")
|
42 |
+
self.api_key_gemini = api_key_gemini or os.getenv("GEMINI_API_KEY", "")
|
43 |
+
|
44 |
+
# Перевірка наявності LlamaIndex
|
45 |
+
if not LLAMA_INDEX_AVAILABLE:
|
46 |
+
logger.error("LlamaIndex не доступний. Встановіть пакети: pip install llama-index-llms-gemini llama-index")
|
47 |
+
raise ImportError("LlamaIndex не встановлено. Необхідний для роботи генератора звітів.")
|
48 |
+
|
49 |
+
# Ініціалізація моделі LLM
|
50 |
+
self.llm = None
|
51 |
+
|
52 |
+
# Дані Jira
|
53 |
+
self.df = None
|
54 |
+
self.jira_documents = []
|
55 |
+
|
56 |
+
# Ініціалізуємо модель LLM
|
57 |
+
self._initialize_llm()
|
58 |
+
|
59 |
+
def _initialize_llm(self):
|
60 |
+
"""Ініціалізує модель LLM відповідно до налаштувань."""
|
61 |
+
try:
|
62 |
+
# Ініціалізація LLM моделі
|
63 |
+
if self.model_type == "gemini" and self.api_key_gemini:
|
64 |
+
os.environ["GEMINI_API_KEY"] = self.api_key_gemini
|
65 |
+
|
66 |
+
from llama_index.llms.gemini import Gemini
|
67 |
+
self.llm = Gemini(
|
68 |
+
model="models/gemini-2.0-flash",
|
69 |
+
temperature=self.temperature,
|
70 |
+
max_tokens=4096,
|
71 |
+
)
|
72 |
+
logger.info("Успішно ініціалізовано Gemini 2.0 Flash модель")
|
73 |
+
elif self.model_type == "openai" and self.api_key_openai:
|
74 |
+
os.environ["OPENAI_API_KEY"] = self.api_key_openai
|
75 |
+
|
76 |
+
from llama_index.llms.openai import OpenAI
|
77 |
+
self.llm = OpenAI(
|
78 |
+
model="gpt-4o-mini",
|
79 |
+
temperature=self.temperature,
|
80 |
+
max_tokens=4096
|
81 |
+
)
|
82 |
+
logger.info("Успішно ініціалізовано OpenAI GPT-4o-mini модель")
|
83 |
+
else:
|
84 |
+
error_msg = f"Не вдалося ініціалізувати LLM модель типу {self.model_type}. Перевірте API ключі."
|
85 |
+
logger.error(error_msg)
|
86 |
+
raise ValueError(error_msg)
|
87 |
+
|
88 |
+
except Exception as e:
|
89 |
+
logger.error(f"Помилка ініціалізації моделі LLM: {e}")
|
90 |
+
raise
|
91 |
+
|
92 |
+
def load_documents_from_dataframe(self, df):
|
93 |
+
"""
|
94 |
+
Завантаження документів прямо з DataFrame без створення індексів.
|
95 |
+
|
96 |
+
Args:
|
97 |
+
df (pandas.DataFrame): DataFrame з даними Jira
|
98 |
+
|
99 |
+
Returns:
|
100 |
+
bool: True якщо дані успішно завантажено
|
101 |
+
"""
|
102 |
+
try:
|
103 |
+
logger.info("Завантаження даних з DataFrame для генерації звіту")
|
104 |
+
|
105 |
+
# Зберігаємо оригінальний DataFrame
|
106 |
+
self.df = df.copy()
|
107 |
+
|
108 |
+
# Конвертуємо дані в документи
|
109 |
+
self._convert_dataframe_to_documents()
|
110 |
+
|
111 |
+
return True
|
112 |
+
|
113 |
+
except Exception as e:
|
114 |
+
logger.error(f"Помил��а при завантаженні даних з DataFrame: {e}")
|
115 |
+
return False
|
116 |
+
|
117 |
+
def _convert_dataframe_to_documents(self):
|
118 |
+
"""
|
119 |
+
Перетворює дані DataFrame в об'єкти Document для роботи з моделлю LLM.
|
120 |
+
"""
|
121 |
+
import pandas as pd
|
122 |
+
|
123 |
+
if self.df is None:
|
124 |
+
logger.error("Не вдалося створити документи: відсутні дані DataFrame")
|
125 |
+
return
|
126 |
+
|
127 |
+
logger.info("Перетворення даних DataFrame в документи для звіту...")
|
128 |
+
|
129 |
+
self.jira_documents = []
|
130 |
+
|
131 |
+
for idx, row in self.df.iterrows():
|
132 |
+
# Основний текст - опис тікета
|
133 |
+
text = ""
|
134 |
+
if 'Description' in row and pd.notna(row['Description']):
|
135 |
+
text = str(row['Description'])
|
136 |
+
|
137 |
+
# Додавання коментарів, якщо вони є
|
138 |
+
for col in self.df.columns:
|
139 |
+
if col.startswith('Comment') and pd.notna(row[col]):
|
140 |
+
text += f"\n\nКоментар: {str(row[col])}"
|
141 |
+
|
142 |
+
# Метадані для документа
|
143 |
+
metadata = {
|
144 |
+
"issue_key": row['Issue key'] if 'Issue key' in row and pd.notna(row['Issue key']) else "",
|
145 |
+
"issue_type": row['Issue Type'] if 'Issue Type' in row and pd.notna(row['Issue Type']) else "",
|
146 |
+
"status": row['Status'] if 'Status' in row and pd.notna(row['Status']) else "",
|
147 |
+
"priority": row['Priority'] if 'Priority' in row and pd.notna(row['Priority']) else "",
|
148 |
+
"assignee": row['Assignee'] if 'Assignee' in row and pd.notna(row['Assignee']) else "",
|
149 |
+
"reporter": row['Reporter'] if 'Reporter' in row and pd.notna(row['Reporter']) else "",
|
150 |
+
"created": str(row['Created']) if 'Created' in row and pd.notna(row['Created']) else "",
|
151 |
+
"updated": str(row['Updated']) if 'Updated' in row and pd.notna(row['Updated']) else "",
|
152 |
+
"summary": row['Summary'] if 'Summary' in row and pd.notna(row['Summary']) else "",
|
153 |
+
"project": row['Project name'] if 'Project name' in row and pd.notna(row['Project name']) else ""
|
154 |
+
}
|
155 |
+
|
156 |
+
# Додатково перевіряємо поле зв'язків, якщо воно є
|
157 |
+
if 'Outward issue link (Relates)' in row and pd.notna(row['Outward issue link (Relates)']):
|
158 |
+
metadata["related_issues"] = row['Outward issue link (Relates)']
|
159 |
+
|
160 |
+
# Додатково перевіряємо інші можливі поля зв'язків
|
161 |
+
for col in self.df.columns:
|
162 |
+
if col.startswith('Outward issue link') and col != 'Outward issue link (Relates)' and pd.notna(row[col]):
|
163 |
+
link_type = col.replace('Outward issue link ', '').strip('()')
|
164 |
+
if "links" not in metadata:
|
165 |
+
metadata["links"] = {}
|
166 |
+
metadata["links"][link_type] = str(row[col])
|
167 |
+
|
168 |
+
# Створення документа
|
169 |
+
doc = Document(
|
170 |
+
text=text,
|
171 |
+
metadata=metadata
|
172 |
+
)
|
173 |
+
|
174 |
+
self.jira_documents.append(doc)
|
175 |
+
|
176 |
+
logger.info(f"Створено {len(self.jira_documents)} документів для генерації звіту")
|
177 |
+
|
178 |
+
def _count_tokens(self, text: str, model: str = "gpt-3.5-turbo") -> int:
|
179 |
+
"""
|
180 |
+
Підраховує приблизну кількість токенів для тексту.
|
181 |
+
|
182 |
+
Args:
|
183 |
+
text (str): Текст для підрахунку токенів
|
184 |
+
model (str): Назва моделі для вибору енкодера
|
185 |
+
|
186 |
+
Returns:
|
187 |
+
int: Кількість токенів
|
188 |
+
"""
|
189 |
+
try:
|
190 |
+
encoding = tiktoken.encoding_for_model(model)
|
191 |
+
tokens = encoding.encode(text)
|
192 |
+
return len(tokens)
|
193 |
+
except Exception as e:
|
194 |
+
logger.warning(f"Не вдалося підрахувати токени через tiktoken: {e}")
|
195 |
+
# Якщо не можемо використати tiktoken, робимо просту оцінку
|
196 |
+
# В середньому 1 токен ≈ 3 символи для змішаного тексту
|
197 |
+
return len(text) // 3 # Приблизна оцінка
|
198 |
+
|
199 |
+
def _prepare_context_data(self):
|
200 |
+
"""
|
201 |
+
Підготовка даних для контексту звіту.
|
202 |
+
|
203 |
+
Returns:
|
204 |
+
str: Підготовлений контекст з даних
|
205 |
+
"""
|
206 |
+
if not self.jira_documents:
|
207 |
+
logger.error("Відсутні документи для підготовки контексту")
|
208 |
+
return ""
|
209 |
+
|
210 |
+
# Статистика по тікетах
|
211 |
+
status_counts = {}
|
212 |
+
type_counts = {}
|
213 |
+
priority_counts = {}
|
214 |
+
assignee_counts = {}
|
215 |
+
|
216 |
+
for doc in self.jira_documents:
|
217 |
+
status = doc.metadata.get("status", "")
|
218 |
+
issue_type = doc.metadata.get("issue_type", "")
|
219 |
+
priority = doc.metadata.get("priority", "")
|
220 |
+
assignee = doc.metadata.get("assignee", "")
|
221 |
+
|
222 |
+
if status:
|
223 |
+
status_counts[status] = status_counts.get(status, 0) + 1
|
224 |
+
if issue_type:
|
225 |
+
type_counts[issue_type] = type_counts.get(issue_type, 0) + 1
|
226 |
+
if priority:
|
227 |
+
priority_counts[priority] = priority_counts.get(priority, 0) + 1
|
228 |
+
if assignee:
|
229 |
+
assignee_counts[assignee] = assignee_counts.get(assignee, 0) + 1
|
230 |
+
|
231 |
+
# Формуємо текстовий опис для LLM
|
232 |
+
data_summary = f"СТАТИСТИКА ПРОЕКТУ JIRA:\n\n"
|
233 |
+
data_summary += f"Загальна кількість тікетів: {len(self.jira_documents)}\n\n"
|
234 |
+
|
235 |
+
data_summary += "Розподіл за статусами:\n"
|
236 |
+
for status, count in sorted(status_counts.items(), key=lambda x: x[1], reverse=True):
|
237 |
+
percentage = (count / len(self.jira_documents) * 100)
|
238 |
+
data_summary += f"- {status}: {count} ({percentage:.1f}%)\n"
|
239 |
+
|
240 |
+
data_summary += "\nРозподіл за типами:\n"
|
241 |
+
for type_name, count in sorted(type_counts.items(), key=lambda x: x[1], reverse=True):
|
242 |
+
percentage = (count / len(self.jira_documents) * 100)
|
243 |
+
data_summary += f"- {type_name}: {count} ({percentage:.1f}%)\n"
|
244 |
+
|
245 |
+
data_summary += "\nРозподіл за пріоритетами:\n"
|
246 |
+
for priority, count in sorted(priority_counts.items(), key=lambda x: x[1], reverse=True):
|
247 |
+
percentage = (count / len(self.jira_documents) * 100)
|
248 |
+
data_summary += f"- {priority}: {count} ({percentage:.1f}%)\n"
|
249 |
+
|
250 |
+
# Топ-5 виконавців
|
251 |
+
if assignee_counts:
|
252 |
+
data_summary += "\nТоп виконавці завдань:\n"
|
253 |
+
for assignee, count in sorted(assignee_counts.items(), key=lambda x: x[1], reverse=True)[:5]:
|
254 |
+
data_summary += f"- {assignee}: {count} тікетів\n"
|
255 |
+
|
256 |
+
# Додаємо інформацію про важливі тікети (з високим пріоритетом)
|
257 |
+
high_priority_tickets = []
|
258 |
+
for doc in self.jira_documents:
|
259 |
+
if doc.metadata.get("priority", "").lower() in ["high", "highest", "critical", "blocker", "високий", "критичний"]:
|
260 |
+
high_priority_tickets.append(doc)
|
261 |
+
|
262 |
+
if high_priority_tickets:
|
263 |
+
data_summary += "\nВажливі тікети (високий пріоритет):\n"
|
264 |
+
for doc in high_priority_tickets[:5]: # Обмежуємо кількість для економії токенів
|
265 |
+
issue_key = doc.metadata.get("issue_key", "")
|
266 |
+
summary = doc.metadata.get("summary", "")
|
267 |
+
status = doc.metadata.get("status", "")
|
268 |
+
data_summary += f"- {issue_key}: '{summary}' (Статус: {status})\n"
|
269 |
+
|
270 |
+
# Додаємо інформацію про останні оновлені тікети
|
271 |
+
try:
|
272 |
+
# Спочатку намагаємося отримати список тікетів з датами оновлення
|
273 |
+
tickets_with_dates = []
|
274 |
+
for doc in self.jira_documents:
|
275 |
+
updated = doc.metadata.get("updated", "")
|
276 |
+
if updated:
|
277 |
+
try:
|
278 |
+
# Спроба парсингу дати
|
279 |
+
updated_date = pd.to_datetime(updated)
|
280 |
+
tickets_with_dates.append((doc, updated_date))
|
281 |
+
except:
|
282 |
+
pass
|
283 |
+
|
284 |
+
# Сортуємо за датою оновлення (від найновіших до найстаріших)
|
285 |
+
tickets_with_dates.sort(key=lambda x: x[1], reverse=True)
|
286 |
+
|
287 |
+
# Додаємо інформацію про останні оновлені тікети
|
288 |
+
if tickets_with_dates:
|
289 |
+
data_summary += "\nОстанні оновлені тікети:\n"
|
290 |
+
for doc, date in tickets_with_dates[:5]:
|
291 |
+
issue_key = doc.metadata.get("issue_key", "")
|
292 |
+
summary = doc.metadata.get("summary", "")
|
293 |
+
status = doc.metadata.get("status", "")
|
294 |
+
data_summary += f"- {issue_key}: '{summary}' (Статус: {status}, Оновлено: {date.strftime('%Y-%m-%d')})\n"
|
295 |
+
except Exception as e:
|
296 |
+
logger.warning(f"Помилка при обробці дат оновлення: {e}")
|
297 |
+
|
298 |
+
return data_summary
|
299 |
+
|
300 |
+
def generate_report(self, format_type="markdown") -> Dict[str, Any]:
|
301 |
+
"""
|
302 |
+
Генерація аналітичного звіту на основі даних Jira.
|
303 |
+
|
304 |
+
Args:
|
305 |
+
format_type (str): Формат звіту ("markdown", "html")
|
306 |
+
|
307 |
+
Returns:
|
308 |
+
Dict[str, Any]: Словник з результатами, включаючи звіт та метадані
|
309 |
+
"""
|
310 |
+
try:
|
311 |
+
if not self.jira_documents or not self.llm:
|
312 |
+
error_msg = "Не вдалося виконати запит: відсутні документи або LLM"
|
313 |
+
logger.error(error_msg)
|
314 |
+
return {"error": error_msg}
|
315 |
+
|
316 |
+
logger.info(f"Запуск генерації звіту у форматі {format_type}")
|
317 |
+
|
318 |
+
# Підготовка контексту з даних
|
319 |
+
data_summary = self._prepare_context_data()
|
320 |
+
|
321 |
+
# Підрахунок токенів для контексту
|
322 |
+
context_tokens = self._count_tokens(data_summary)
|
323 |
+
logger.info(f"Підготовлено контекст для звіту: {context_tokens} токенів")
|
324 |
+
|
325 |
+
# Отримуємо системний промпт відповідно до формату
|
326 |
+
system_prompt = get_report_prompt(format_type)
|
327 |
+
|
328 |
+
# Формуємо повідомлення для чату
|
329 |
+
messages = [
|
330 |
+
ChatMessage(role="system", content=system_prompt),
|
331 |
+
ChatMessage(role="user", content=f"Ось дані для аналізу:\n\n{data_summary}")
|
332 |
+
]
|
333 |
+
|
334 |
+
# Отримуємо відповідь від LLM
|
335 |
+
logger.info("Генерація звіту...")
|
336 |
+
response = self.llm.chat(messages)
|
337 |
+
|
338 |
+
# Підрахунок токенів для відповіді
|
339 |
+
report_text = str(response)
|
340 |
+
response_tokens = self._count_tokens(report_text)
|
341 |
+
|
342 |
+
logger.info(f"Звіт успішно згенеровано, токенів: {response_tokens}")
|
343 |
+
|
344 |
+
return {
|
345 |
+
"report": report_text,
|
346 |
+
"metadata": {
|
347 |
+
"context_tokens": context_tokens,
|
348 |
+
"report_tokens": response_tokens,
|
349 |
+
"total_tokens": context_tokens + response_tokens,
|
350 |
+
"format": format_type,
|
351 |
+
"documents_used": len(self.jira_documents)
|
352 |
+
}
|
353 |
+
}
|
354 |
+
|
355 |
+
except Exception as e:
|
356 |
+
error_msg = f"Помилка при генерації звіту: {e}"
|
357 |
+
logger.error(error_msg)
|
358 |
+
return {"error": error_msg}
|
359 |
+
|
360 |
+
def get_statistics(self) -> Dict[str, Any]:
|
361 |
+
"""
|
362 |
+
Повертає загальну статистику за документами.
|
363 |
+
|
364 |
+
Returns:
|
365 |
+
Dict[str, Any]: Словник зі статистикою
|
366 |
+
"""
|
367 |
+
if not self.jira_documents:
|
368 |
+
return {"error": "Немає завантажених документів"}
|
369 |
+
|
370 |
+
# Статистика по тікетах
|
371 |
+
status_counts = {}
|
372 |
+
type_counts = {}
|
373 |
+
priority_counts = {}
|
374 |
+
assignee_counts = {}
|
375 |
+
|
376 |
+
for doc in self.jira_documents:
|
377 |
+
status = doc.metadata.get("status", "")
|
378 |
+
issue_type = doc.metadata.get("issue_type", "")
|
379 |
+
priority = doc.metadata.get("priority", "")
|
380 |
+
assignee = doc.metadata.get("assignee", "")
|
381 |
+
|
382 |
+
if status:
|
383 |
+
status_counts[status] = status_counts.get(status, 0) + 1
|
384 |
+
if issue_type:
|
385 |
+
type_counts[issue_type] = type_counts.get(issue_type, 0) + 1
|
386 |
+
if priority:
|
387 |
+
priority_counts[priority] = priority_counts.get(priority, 0) + 1
|
388 |
+
if assignee:
|
389 |
+
assignee_counts[assignee] = assignee_counts.get(assignee, 0) + 1
|
390 |
+
|
391 |
+
# Формуємо результат
|
392 |
+
return {
|
393 |
+
"document_count": len(self.jira_documents),
|
394 |
+
"status_counts": status_counts,
|
395 |
+
"type_counts": type_counts,
|
396 |
+
"priority_counts": priority_counts,
|
397 |
+
"top_assignees": dict(sorted(assignee_counts.items(), key=lambda x: x[1], reverse=True)[:5])
|
398 |
+
}
|
modules/ai_analysis/jira_hybrid_chat.py
ADDED
@@ -0,0 +1,669 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import logging
|
2 |
+
import traceback
|
3 |
+
import os
|
4 |
+
import json
|
5 |
+
from pathlib import Path
|
6 |
+
from typing import Dict, List, Any, Optional
|
7 |
+
import tiktoken
|
8 |
+
import pandas as pd
|
9 |
+
from datetime import datetime
|
10 |
+
|
11 |
+
from modules.config.ai_settings import (
|
12 |
+
get_metadata_csv,
|
13 |
+
MAX_TOKENS,
|
14 |
+
CHUNK_SIZE, CHUNK_OVERLAP,
|
15 |
+
EXCLUDED_EMBED_METADATA_KEYS,
|
16 |
+
EXCLUDED_LLM_METADATA_KEYS,
|
17 |
+
SIMILARITY_TOP_K, HYBRID_SEARCH_MODE
|
18 |
+
)
|
19 |
+
from prompts import system_prompt_hybrid_chat
|
20 |
+
|
21 |
+
# Імпорт базових компонентів LlamaIndex
|
22 |
+
from llama_index.core import (
|
23 |
+
VectorStoreIndex,
|
24 |
+
Document,
|
25 |
+
StorageContext,
|
26 |
+
load_index_from_storage,
|
27 |
+
Settings
|
28 |
+
)
|
29 |
+
from llama_index.vector_stores.faiss import FaissVectorStore
|
30 |
+
from llama_index.retrievers.bm25 import BM25Retriever
|
31 |
+
from llama_index.core.query_engine import RetrieverQueryEngine
|
32 |
+
from llama_index.core.retrievers import QueryFusionRetriever
|
33 |
+
from llama_index.core.llms import ChatMessage
|
34 |
+
|
35 |
+
# Імпорт уніфікованого менеджера індексів
|
36 |
+
import builtins
|
37 |
+
|
38 |
+
# Забезпечення бінарного формату для всіх операцій
|
39 |
+
Settings.persist_json_format = False
|
40 |
+
|
41 |
+
from modules.data_management.index_utils import count_tokens, initialize_embedding_model, check_index_integrity
|
42 |
+
|
43 |
+
os.environ["CUDA_VISIBLE_DEVICES"] = ""
|
44 |
+
os.environ["TORCH_DEVICE"] = "cpu"
|
45 |
+
|
46 |
+
logger = logging.getLogger(__name__)
|
47 |
+
|
48 |
+
class JiraHybridChat:
|
49 |
+
"""
|
50 |
+
Клас для роботи з гібридним чатом на основі даних Jira.
|
51 |
+
Використовує комбінацію BM25 та векторного пошуку для покращення релевантності.
|
52 |
+
"""
|
53 |
+
# Ліміт кешу екземплярів
|
54 |
+
MAX_CACHE_SIZE = 5
|
55 |
+
# Глобальний кеш екземплярів чату
|
56 |
+
chat_instances_cache = {}
|
57 |
+
|
58 |
+
def __init__(
|
59 |
+
self,
|
60 |
+
indices_dir=None,
|
61 |
+
app=None,
|
62 |
+
api_key_openai=None,
|
63 |
+
api_key_gemini=None,
|
64 |
+
model_type="gemini",
|
65 |
+
model_name=None,
|
66 |
+
temperature=0.2,
|
67 |
+
):
|
68 |
+
"""
|
69 |
+
Args:
|
70 |
+
indices_dir (str): Шлях до директорії з індексами
|
71 |
+
app: будь-який об'єкт, звідки беремо current_data (DataFrame)
|
72 |
+
api_key_openai (str): ключ OpenAI
|
73 |
+
api_key_gemini (str): ключ Google Gemini
|
74 |
+
model_type (str): "gemini" або "openai"
|
75 |
+
model_name (str): назва моделі
|
76 |
+
temperature (float): температура LLM
|
77 |
+
"""
|
78 |
+
self.indices_dir = indices_dir
|
79 |
+
self.app = app
|
80 |
+
self.model_type = model_type.lower()
|
81 |
+
self.model_name = model_name
|
82 |
+
self.temperature = temperature
|
83 |
+
|
84 |
+
self.llm_initialized = False
|
85 |
+
self.indices_loaded = False
|
86 |
+
|
87 |
+
self.api_key_openai = api_key_openai or os.getenv("OPENAI_API_KEY", "")
|
88 |
+
self.api_key_gemini = api_key_gemini or os.getenv("GEMINI_API_KEY", "")
|
89 |
+
|
90 |
+
# Проставляємо змінні середовища
|
91 |
+
if self.api_key_openai:
|
92 |
+
os.environ["OPENAI_API_KEY"] = self.api_key_openai
|
93 |
+
if self.api_key_gemini:
|
94 |
+
os.environ["GEMINI_API_KEY"] = self.api_key_gemini
|
95 |
+
|
96 |
+
# Основні поля
|
97 |
+
self.llm = None
|
98 |
+
self.index = None
|
99 |
+
self.retriever_bm25 = None
|
100 |
+
self.retriever_vector = None
|
101 |
+
self.retriever_fusion = None
|
102 |
+
self.query_engine = None
|
103 |
+
self.df = None
|
104 |
+
self.jira_documents = []
|
105 |
+
self.nodes = []
|
106 |
+
|
107 |
+
# Отримуємо index_manager з глобальної змінної, якщо доступний
|
108 |
+
self.index_manager = None
|
109 |
+
if hasattr(builtins, 'index_manager'):
|
110 |
+
self.index_manager = builtins.index_manager
|
111 |
+
logger.info("Використовується глобальний index_manager")
|
112 |
+
|
113 |
+
# Додаткові параметри
|
114 |
+
self.similarity_top_k = SIMILARITY_TOP_K
|
115 |
+
self.hybrid_mode = HYBRID_SEARCH_MODE
|
116 |
+
|
117 |
+
# Ініціалізація в оптимізованому порядку
|
118 |
+
self._initialize()
|
119 |
+
|
120 |
+
def _initialize(self):
|
121 |
+
"""Ініціалізація в правильному порядку для уникнення дублювання."""
|
122 |
+
# 1) Ініціалізуємо LLM
|
123 |
+
self._initialize_llm()
|
124 |
+
|
125 |
+
# 2) Перевіряємо кеш
|
126 |
+
if self.indices_dir and self.indices_dir in JiraHybridChat.chat_instances_cache:
|
127 |
+
cached_instance = JiraHybridChat.chat_instances_cache[self.indices_dir]
|
128 |
+
if cached_instance.index is not None:
|
129 |
+
self._load_from_cache(cached_instance)
|
130 |
+
self.indices_loaded = True
|
131 |
+
logger.info(f"Використано кешований екземпляр для {self.indices_dir}")
|
132 |
+
return
|
133 |
+
|
134 |
+
# 3) Спробуємо завантажити з вказаного шляху
|
135 |
+
if self.indices_dir and self.load_indices(self.indices_dir):
|
136 |
+
self.indices_loaded = True
|
137 |
+
return
|
138 |
+
|
139 |
+
# 4) Завантажуємо дані для створення нових індексів
|
140 |
+
df = self._get_dataframe()
|
141 |
+
if df is None:
|
142 |
+
return
|
143 |
+
|
144 |
+
# 5) Створюємо документи
|
145 |
+
self.df = df
|
146 |
+
self.jira_documents = self._create_documents_from_dataframe(df)
|
147 |
+
if not self.jira_documents:
|
148 |
+
return
|
149 |
+
|
150 |
+
# 6) Створюємо індекси в пам'яті
|
151 |
+
if self._create_indices_in_memory():
|
152 |
+
self.indices_loaded = True
|
153 |
+
|
154 |
+
# 7) Зберігаємо на диск, якщо вказано indices_dir
|
155 |
+
if self.indices_dir:
|
156 |
+
self._persist_indices_to_disk(self.indices_dir)
|
157 |
+
|
158 |
+
# 8) Кешуємо екземпляр
|
159 |
+
self._add_to_cache()
|
160 |
+
|
161 |
+
def _initialize_llm(self):
|
162 |
+
"""Ініціалізація LLM залежно від model_type (gemini / openai)."""
|
163 |
+
try:
|
164 |
+
if self.model_type == "gemini" and self.api_key_gemini:
|
165 |
+
from llama_index.llms.gemini import Gemini
|
166 |
+
if not self.model_name:
|
167 |
+
self.model_name = "models/gemini-2.0-flash"
|
168 |
+
|
169 |
+
self.llm = Gemini(
|
170 |
+
model=self.model_name,
|
171 |
+
temperature=self.temperature,
|
172 |
+
max_tokens=MAX_TOKENS,
|
173 |
+
)
|
174 |
+
logger.info(f"Успішно ініціалізовано Gemini модель: {self.model_name}")
|
175 |
+
self.llm_initialized = True
|
176 |
+
|
177 |
+
elif self.model_type == "openai" and self.api_key_openai:
|
178 |
+
from llama_index.llms.openai import OpenAI
|
179 |
+
if not self.model_name:
|
180 |
+
self.model_name = "gpt-4o-mini"
|
181 |
+
|
182 |
+
self.llm = OpenAI(
|
183 |
+
model=self.model_name,
|
184 |
+
temperature=self.temperature,
|
185 |
+
max_tokens=MAX_TOKENS
|
186 |
+
)
|
187 |
+
logger.info(f"Успішно ініціалізовано OpenAI модель: {self.model_name}")
|
188 |
+
self.llm_initialized = True
|
189 |
+
|
190 |
+
else:
|
191 |
+
error_msg = f"Не вдалося ініціалізувати LLM {self.model_type}. Перевірте ключі."
|
192 |
+
logger.error(error_msg)
|
193 |
+
raise ValueError(error_msg)
|
194 |
+
|
195 |
+
except Exception as e:
|
196 |
+
logger.error(f"Помилка ініціалізації LLM: {e}")
|
197 |
+
logger.error(traceback.format_exc())
|
198 |
+
|
199 |
+
def _load_from_cache(self, cached_instance):
|
200 |
+
"""Копіюємо дані з кешованого екземпляра."""
|
201 |
+
self.index = cached_instance.index
|
202 |
+
self.retriever_bm25 = cached_instance.retriever_bm25
|
203 |
+
self.retriever_vector = cached_instance.retriever_vector
|
204 |
+
self.retriever_fusion = cached_instance.retriever_fusion
|
205 |
+
self.query_engine = cached_instance.query_engine
|
206 |
+
self.jira_documents = cached_instance.jira_documents
|
207 |
+
self.nodes = cached_instance.nodes
|
208 |
+
self.df = cached_instance.df
|
209 |
+
|
210 |
+
def _add_to_cache(self):
|
211 |
+
"""Додаємо поточний екземпляр у кеш."""
|
212 |
+
if not self.index or self.indices_dir is None:
|
213 |
+
return
|
214 |
+
|
215 |
+
# Якщо кеш переповнений, видаляємо найстаріший запис
|
216 |
+
if len(JiraHybridChat.chat_instances_cache) >= self.MAX_CACHE_SIZE:
|
217 |
+
oldest_key = next(iter(JiraHybridChat.chat_instances_cache))
|
218 |
+
JiraHybridChat.chat_instances_cache.pop(oldest_key)
|
219 |
+
|
220 |
+
# Додаємо поточний екземпляр у кеш
|
221 |
+
JiraHybridChat.chat_instances_cache[self.indices_dir] = self
|
222 |
+
logger.info(f"Додано екземпляр у кеш для {self.indices_dir}")
|
223 |
+
|
224 |
+
def _get_dataframe(self):
|
225 |
+
"""Отримуємо DataFrame з app.current_data або з CSV файлу."""
|
226 |
+
# Спочатку пробуємо отримати з app.current_data
|
227 |
+
if hasattr(self, "app") and hasattr(self.app, "current_data"):
|
228 |
+
if isinstance(self.app.current_data, pd.DataFrame) and not self.app.current_data.empty:
|
229 |
+
logger.info(f"Отримано DataFrame з app.current_data: {len(self.app.current_data)} рядків")
|
230 |
+
return self.app.current_data
|
231 |
+
|
232 |
+
# Пробуємо отримати з app.last_loaded_csv
|
233 |
+
if hasattr(self, "app") and hasattr(self.app, "last_loaded_csv"):
|
234 |
+
csv_path = self.app.last_loaded_csv
|
235 |
+
if csv_path and os.path.exists(csv_path):
|
236 |
+
try:
|
237 |
+
df = pd.read_csv(csv_path)
|
238 |
+
logger.info(f"Завантажено DataFrame з CSV: {len(df)} рядків")
|
239 |
+
return df
|
240 |
+
except Exception as e:
|
241 |
+
logger.warning(f"Помилка при читанні CSV: {e}")
|
242 |
+
|
243 |
+
logger.warning("Немає доступних даних для створення індексів.")
|
244 |
+
return None
|
245 |
+
|
246 |
+
def _create_documents_from_dataframe(self, df):
|
247 |
+
"""Конвертуємо DataFrame у список документів."""
|
248 |
+
documents = []
|
249 |
+
|
250 |
+
for idx, row in df.iterrows():
|
251 |
+
# Основний текст документа
|
252 |
+
text = ""
|
253 |
+
if 'Description' in row and pd.notna(row['Description']):
|
254 |
+
text = str(row['Description'])
|
255 |
+
|
256 |
+
# Додаємо коментарі до тексту
|
257 |
+
for col in df.columns:
|
258 |
+
if col.startswith("Comment") and pd.notna(row[col]):
|
259 |
+
text += f"\n\nКоментар: {row[col]}"
|
260 |
+
|
261 |
+
# Метадані документа
|
262 |
+
metadata = get_metadata_csv(row, idx)
|
263 |
+
excluded_embed_metadata_keys = []
|
264 |
+
excluded_llm_metadata_keys = []
|
265 |
+
|
266 |
+
# Створюємо документ
|
267 |
+
doc = Document(
|
268 |
+
text=text,
|
269 |
+
metadata=metadata,
|
270 |
+
metadata_seperator="::",
|
271 |
+
excluded_embed_metadata_keys=excluded_embed_metadata_keys,
|
272 |
+
excluded_llm_metadata_keys=excluded_llm_metadata_keys,
|
273 |
+
text_template="Metadata: {metadata_str}\n-----\nContent: {content}",
|
274 |
+
)
|
275 |
+
documents.append(doc)
|
276 |
+
|
277 |
+
logger.info(f"Створено {len(documents)} документів для індексів")
|
278 |
+
return documents
|
279 |
+
|
280 |
+
def _create_indices_in_memory(self):
|
281 |
+
"""Створюємо індекси FAISS в пам'яті."""
|
282 |
+
try:
|
283 |
+
if not self.jira_documents:
|
284 |
+
return False
|
285 |
+
|
286 |
+
# Ініціалізуємо модель ембедингів
|
287 |
+
try:
|
288 |
+
embed_model = initialize_embedding_model()
|
289 |
+
|
290 |
+
if embed_model is None:
|
291 |
+
logger.error("Не вдалося отримати модель ембедингів")
|
292 |
+
return False
|
293 |
+
|
294 |
+
# Встановлюємо модель ембедингів у глобальних налаштуваннях
|
295 |
+
Settings.embed_model = embed_model
|
296 |
+
|
297 |
+
# Перевіряємо, чи це Google Embeddings
|
298 |
+
if "CustomEmbedding" in str(type(embed_model)):
|
299 |
+
logger.info("Виявлено Google Embeddings API")
|
300 |
+
# Отримуємо розмірність ембедингів через тестовий запит
|
301 |
+
test_embedding = embed_model.get_text_embedding("Test")
|
302 |
+
embed_dim = len(test_embedding)
|
303 |
+
logger.info(f"Розмірність ембедингів Google: {embed_dim}")
|
304 |
+
else:
|
305 |
+
# Це HuggingFace або інший тип ембедингів
|
306 |
+
sample_embedding = embed_model.get_text_embedding("Test")
|
307 |
+
embed_dim = len(sample_embedding)
|
308 |
+
logger.info(f"Розмірність локальних ембедингів: {embed_dim}")
|
309 |
+
|
310 |
+
except Exception as embed_error:
|
311 |
+
logger.error(f"Помилка при ініціалізації моделі ембедингів: {embed_error}")
|
312 |
+
logger.error(traceback.format_exc())
|
313 |
+
return False
|
314 |
+
|
315 |
+
# Створюємо та налаштовуємо індекс
|
316 |
+
from llama_index.core.node_parser import TokenTextSplitter
|
317 |
+
|
318 |
+
# Розділювач тексту для чанкінгу
|
319 |
+
text_splitter = TokenTextSplitter(
|
320 |
+
chunk_size=CHUNK_SIZE,
|
321 |
+
chunk_overlap=CHUNK_OVERLAP
|
322 |
+
)
|
323 |
+
|
324 |
+
# Створюємо FAISS індекс
|
325 |
+
try:
|
326 |
+
import faiss
|
327 |
+
faiss_index = faiss.IndexFlatL2(embed_dim)
|
328 |
+
vector_store = FaissVectorStore(faiss_index=faiss_index)
|
329 |
+
|
330 |
+
# Створюємо контекст зберігання
|
331 |
+
storage_context = StorageContext.from_defaults(vector_store=vector_store)
|
332 |
+
|
333 |
+
# Створюємо індекс
|
334 |
+
self.index = VectorStoreIndex.from_documents(
|
335 |
+
self.jira_documents,
|
336 |
+
storage_context=storage_context,
|
337 |
+
transformations=[text_splitter]
|
338 |
+
)
|
339 |
+
|
340 |
+
# Зберігаємо вузли для подальшого використання
|
341 |
+
self.nodes = list(self.index.storage_context.docstore.docs.values())
|
342 |
+
|
343 |
+
# Налаштування retrievers
|
344 |
+
self._setup_retrievers()
|
345 |
+
|
346 |
+
logger.info("Індекси успішно створено в пам'яті")
|
347 |
+
return True
|
348 |
+
|
349 |
+
except Exception as index_error:
|
350 |
+
logger.error(f"Помилка при створенні FAISS індексу: {index_error}")
|
351 |
+
logger.error(traceback.format_exc())
|
352 |
+
return False
|
353 |
+
|
354 |
+
except Exception as e:
|
355 |
+
logger.error(f"Загальна помилка при створенні індексів: {e}")
|
356 |
+
logger.error(traceback.format_exc())
|
357 |
+
return False
|
358 |
+
|
359 |
+
def _setup_retrievers(self):
|
360 |
+
"""Налаштовуємо різні типи retrievers для пошуку."""
|
361 |
+
docstore = self.index.storage_context.docstore
|
362 |
+
|
363 |
+
# BM25 retriever (пошук за ключовими словами)
|
364 |
+
self.retriever_bm25 = BM25Retriever.from_defaults(
|
365 |
+
docstore=docstore,
|
366 |
+
similarity_top_k=self.similarity_top_k
|
367 |
+
)
|
368 |
+
|
369 |
+
# Векторний retriever (семантичний пошук)
|
370 |
+
self.retriever_vector = self.index.as_retriever(
|
371 |
+
similarity_top_k=self.similarity_top_k
|
372 |
+
)
|
373 |
+
|
374 |
+
# Гібридний retriever (комбінація BM25 та векторного)
|
375 |
+
self.retriever_fusion = QueryFusionRetriever(
|
376 |
+
[self.retriever_bm25, self.retriever_vector],
|
377 |
+
mode=self.hybrid_mode,
|
378 |
+
similarity_top_k=self.similarity_top_k,
|
379 |
+
num_queries=1,
|
380 |
+
use_async=True
|
381 |
+
)
|
382 |
+
|
383 |
+
# Query engine для виконання запитів
|
384 |
+
self.query_engine = RetrieverQueryEngine(self.retriever_fusion)
|
385 |
+
|
386 |
+
def _persist_indices_to_disk(self, indices_dir):
|
387 |
+
"""Зберігаємо індекси на диск."""
|
388 |
+
try:
|
389 |
+
# Створюємо директорію, якщо її не існує
|
390 |
+
Path(indices_dir).mkdir(parents=True, exist_ok=True)
|
391 |
+
|
392 |
+
# Якщо індекси вже створені в пам'яті, просто зберігаємо їх на диск
|
393 |
+
if self.index is not None:
|
394 |
+
# Забезпечуємо бінарний формат збереження
|
395 |
+
Settings.persist_json_format = False
|
396 |
+
|
397 |
+
# Очищаємо директорію перед збереженням
|
398 |
+
path_dir = Path(indices_dir)
|
399 |
+
if path_dir.exists():
|
400 |
+
for item in path_dir.iterdir():
|
401 |
+
if item.is_file():
|
402 |
+
item.unlink()
|
403 |
+
elif item.is_dir():
|
404 |
+
import shutil
|
405 |
+
shutil.rmtree(item)
|
406 |
+
|
407 |
+
# Зберігаємо індекси
|
408 |
+
self.index.storage_context.persist(persist_dir=indices_dir)
|
409 |
+
|
410 |
+
# Створюємо BM25 директорію і зберігаємо параметри
|
411 |
+
bm25_dir = Path(indices_dir) / "bm25"
|
412 |
+
bm25_dir.mkdir(exist_ok=True)
|
413 |
+
|
414 |
+
# Зберігаємо параметри BM25
|
415 |
+
bm25_params = {
|
416 |
+
"similarity_top_k": self.retriever_bm25.similarity_top_k,
|
417 |
+
"index_creation_time": datetime.now().isoformat()
|
418 |
+
}
|
419 |
+
|
420 |
+
with open(bm25_dir / "params.json", "w", encoding="utf-8") as f:
|
421 |
+
json.dump(bm25_params, f, ensure_ascii=False, indent=2)
|
422 |
+
|
423 |
+
# Створюємо маркерний файл
|
424 |
+
with open(os.path.join(indices_dir, "indices.valid"), "w") as f:
|
425 |
+
f.write(f"Indices created at {datetime.now().isoformat()}")
|
426 |
+
|
427 |
+
# Зберігаємо метадані
|
428 |
+
metadata = {
|
429 |
+
"created_at": datetime.now().isoformat(),
|
430 |
+
"documents_count": len(self.jira_documents),
|
431 |
+
"storage_format": "binary"
|
432 |
+
}
|
433 |
+
|
434 |
+
with open(os.path.join(indices_dir, "metadata.json"), "w", encoding="utf-8") as f:
|
435 |
+
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
436 |
+
|
437 |
+
# Оновлюємо шлях у глобальному index_manager, якщо він доступний
|
438 |
+
if self.index_manager and hasattr(self.index_manager, 'register_indices_path'):
|
439 |
+
session_id = getattr(self.app, 'current_session_id', None)
|
440 |
+
self.index_manager.register_indices_path(indices_dir, session_id)
|
441 |
+
|
442 |
+
self.indices_dir = indices_dir
|
443 |
+
logger.info(f"Індекси успішно збережено у: {indices_dir}")
|
444 |
+
return True
|
445 |
+
|
446 |
+
# Якщо індекси ще не створено, але є index_manager - використовуємо його
|
447 |
+
elif self.index_manager and self.df is not None:
|
448 |
+
# Генеруємо унікальний ідентифікатор сесії, якщо він відсутній
|
449 |
+
if not hasattr(self.app, 'current_session_id') or self.app.current_session_id is None:
|
450 |
+
import uuid
|
451 |
+
session_id = f"{uuid.uuid4()}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
452 |
+
else:
|
453 |
+
session_id = self.app.current_session_id
|
454 |
+
|
455 |
+
# Реєструємо або створюємо нові індекси
|
456 |
+
if hasattr(self.index_manager, 'register_indices_path'):
|
457 |
+
self.index_manager.register_indices_path(indices_dir, session_id)
|
458 |
+
self.indices_dir = indices_dir
|
459 |
+
return True
|
460 |
+
else:
|
461 |
+
# Резервний варіант - створюємо нові індекси через manager
|
462 |
+
index_result = self.index_manager.get_or_create_indices(self.df, session_id)
|
463 |
+
|
464 |
+
if "indices_dir" in index_result:
|
465 |
+
self.indices_dir = index_result["indices_dir"]
|
466 |
+
logger.info(f"Індекси збережені через index_manager: {self.indices_dir}")
|
467 |
+
return True
|
468 |
+
else:
|
469 |
+
logger.error(f"Помилка при збереженні індексів через index_manager: {index_result.get('error', 'невідома помилка')}")
|
470 |
+
return False
|
471 |
+
else:
|
472 |
+
logger.error("Немає індексів для збереження")
|
473 |
+
return False
|
474 |
+
|
475 |
+
except Exception as e:
|
476 |
+
logger.error(f"Помилка при збереженні індексів: {e}")
|
477 |
+
logger.error(traceback.format_exc())
|
478 |
+
return False
|
479 |
+
|
480 |
+
def load_indices(self, indices_dir):
|
481 |
+
"""Завантаження індексів з директорії, якщо вони існують."""
|
482 |
+
try:
|
483 |
+
# Перевіряємо цілісність індексів
|
484 |
+
is_valid, error_msg = check_index_integrity(indices_dir)
|
485 |
+
if not is_valid:
|
486 |
+
logger.warning(f"Файл маркера не знайдено в {indices_dir}: {error_msg}")
|
487 |
+
return False
|
488 |
+
|
489 |
+
# Забезпечуємо бінарний формат для завантаження
|
490 |
+
Settings.persist_json_format = False
|
491 |
+
|
492 |
+
# Діагностика вмісту директорії
|
493 |
+
import glob
|
494 |
+
logger.info(f"Файли у директорії {indices_dir}: {glob.glob(os.path.join(indices_dir, '*'))}")
|
495 |
+
|
496 |
+
# Завантаження за приклад з LlamaIndex
|
497 |
+
try:
|
498 |
+
# Створюємо vector_store з директорії
|
499 |
+
vector_store = FaissVectorStore.from_persist_dir(indices_dir)
|
500 |
+
|
501 |
+
# Створюємо storage_context
|
502 |
+
storage_context = StorageContext.from_defaults(
|
503 |
+
vector_store=vector_store,
|
504 |
+
persist_dir=indices_dir
|
505 |
+
)
|
506 |
+
|
507 |
+
# Завантажуємо індекс
|
508 |
+
self.index = load_index_from_storage(
|
509 |
+
storage_context=storage_context,
|
510 |
+
index_cls=VectorStoreIndex
|
511 |
+
)
|
512 |
+
|
513 |
+
# Налаштовуємо retrievers
|
514 |
+
self._setup_retrievers()
|
515 |
+
|
516 |
+
logger.info(f"Індекси успішно завантажені з: {indices_dir}")
|
517 |
+
return True
|
518 |
+
|
519 |
+
except Exception as e:
|
520 |
+
logger.error(f"Проблема при завантаженні індексів: {e}")
|
521 |
+
logger.error(traceback.format_exc())
|
522 |
+
return False
|
523 |
+
|
524 |
+
except Exception as e:
|
525 |
+
logger.error(f"Помилка при завантаженні індексів: {e}")
|
526 |
+
logger.error(traceback.format_exc())
|
527 |
+
return False
|
528 |
+
|
529 |
+
def chat_with_hybrid_search(self, question, chat_history=None):
|
530 |
+
"""Виконуємо гібридний пошук і отримуємо відповідь від LLM."""
|
531 |
+
if not self.llm or not self.retriever_fusion:
|
532 |
+
error_msg = "Не вдалося виконати запит: LLM або індекси не ініціалізовано."
|
533 |
+
logger.error(error_msg)
|
534 |
+
return {"error": error_msg}
|
535 |
+
|
536 |
+
try:
|
537 |
+
logger.info(f"Обробка запиту: {question}")
|
538 |
+
question_tokens = count_tokens(question)
|
539 |
+
|
540 |
+
# Виконуємо пошук
|
541 |
+
logger.info("Виконання гібридного пошуку за запитом")
|
542 |
+
nodes = self.retriever_fusion.retrieve(question)
|
543 |
+
|
544 |
+
# Формуємо контекст
|
545 |
+
context = "ЗНАЙДЕНІ РЕЛЕВАНТНІ ДОКУМЕНТИ:\n\n"
|
546 |
+
relevant_docs = []
|
547 |
+
|
548 |
+
for i, node in enumerate(nodes):
|
549 |
+
context += f"Документ {i+1} (релевантність: {node.score:.4f}):\n"
|
550 |
+
ticket_id = node.metadata.get("issue_key", f"TICKET-{i+1}")
|
551 |
+
summary = node.metadata.get("summary", "Без опису")
|
552 |
+
context += f"ТІКЕТ {ticket_id}: {summary}\n"
|
553 |
+
|
554 |
+
# Додаємо метадані
|
555 |
+
for k, v in node.metadata.items():
|
556 |
+
if k in EXCLUDED_LLM_METADATA_KEYS or k in ["summary", "issue_key", "node_info"]:
|
557 |
+
continue
|
558 |
+
if v:
|
559 |
+
context += f"{k}: {v}\n"
|
560 |
+
|
561 |
+
# Додаємо текст документа
|
562 |
+
if node.text:
|
563 |
+
context += f"Опис: {node.text}\n"
|
564 |
+
|
565 |
+
context += "\n" + "-"*40 + "\n\n"
|
566 |
+
|
567 |
+
# Зберігаємо інформацію про документ
|
568 |
+
relevant_docs.append({
|
569 |
+
"rank": i+1,
|
570 |
+
"relevance": node.score,
|
571 |
+
"ticket_id": ticket_id,
|
572 |
+
"summary": summary
|
573 |
+
})
|
574 |
+
|
575 |
+
# Рахуємо токени в контексті
|
576 |
+
context_tokens = count_tokens(context)
|
577 |
+
|
578 |
+
# Формуємо повідомлення для LLM
|
579 |
+
messages = []
|
580 |
+
messages.append(ChatMessage(role="system", content=system_prompt_hybrid_chat))
|
581 |
+
|
582 |
+
# Додаємо історію чату, якщо вона є
|
583 |
+
if chat_history:
|
584 |
+
for h in chat_history:
|
585 |
+
role_ = h.get("role", "user")
|
586 |
+
content_ = h.get("content", "")
|
587 |
+
if role_ in ["user", "assistant", "system"]:
|
588 |
+
messages.append(ChatMessage(role=role_, content=content_))
|
589 |
+
|
590 |
+
# Додаємо контекст і питання
|
591 |
+
messages.append(ChatMessage(role="system", content=f"Контекст:\n\n{context}"))
|
592 |
+
messages.append(ChatMessage(role="user", content=question))
|
593 |
+
|
594 |
+
# Відправляємо запит до LLM
|
595 |
+
logger.info(f"Відправка запиту до LLM (токени: питання={question_tokens}, контекст={context_tokens})")
|
596 |
+
response = self.llm.chat(messages)
|
597 |
+
response_text = str(response)
|
598 |
+
|
599 |
+
# Рахуємо токени відповіді
|
600 |
+
response_tokens = count_tokens(response_text)
|
601 |
+
logger.info(f"Отримано відповідь від LLM (токени: відповідь={response_tokens})")
|
602 |
+
|
603 |
+
# Формуємо результат
|
604 |
+
return {
|
605 |
+
"answer": response_text,
|
606 |
+
"metadata": {
|
607 |
+
"question_tokens": question_tokens,
|
608 |
+
"context_tokens": context_tokens,
|
609 |
+
"response_tokens": response_tokens,
|
610 |
+
"total_tokens": question_tokens + context_tokens + response_tokens,
|
611 |
+
"relevant_documents": relevant_docs[:self.similarity_top_k]
|
612 |
+
}
|
613 |
+
}
|
614 |
+
|
615 |
+
except Exception as e:
|
616 |
+
error_msg = f"Помилка при виконанні запиту: {e}"
|
617 |
+
logger.error(error_msg)
|
618 |
+
logger.error(traceback.format_exc())
|
619 |
+
return {"error": error_msg}
|
620 |
+
|
621 |
+
# --- Допоміжні методи для сумісності ---
|
622 |
+
|
623 |
+
def chat(self, question, history=None):
|
624 |
+
"""Скорочений метод, повертає лише текст відповіді."""
|
625 |
+
result = self.chat_with_hybrid_search(question, history)
|
626 |
+
if "error" in result:
|
627 |
+
return f"Помилка: {result['error']}"
|
628 |
+
return result["answer"]
|
629 |
+
|
630 |
+
def run_qa(self, question, history=None):
|
631 |
+
"""Сумісність із jira_qa_assistant.py"""
|
632 |
+
return self.chat_with_hybrid_search(question, history)
|
633 |
+
|
634 |
+
def run_full_context_qa(self, question):
|
635 |
+
"""Запит без історії."""
|
636 |
+
return self.chat_with_hybrid_search(question)
|
637 |
+
|
638 |
+
def load_data_from_dataframe(self, df):
|
639 |
+
"""Завантаження даних з DataFrame."""
|
640 |
+
try:
|
641 |
+
self.df = df.copy()
|
642 |
+
self.jira_documents = self._create_documents_from_dataframe(df)
|
643 |
+
if self._create_indices_in_memory():
|
644 |
+
self.indices_loaded = True
|
645 |
+
return True
|
646 |
+
return False
|
647 |
+
except Exception as e:
|
648 |
+
logger.error(f"Помилка при завантаженні даних з DataFrame: {e}")
|
649 |
+
return False
|
650 |
+
|
651 |
+
def load_data_from_csv(self, file_path):
|
652 |
+
"""Завантаження даних з CSV файлу."""
|
653 |
+
try:
|
654 |
+
df = pd.read_csv(file_path)
|
655 |
+
return self.load_data_from_dataframe(df)
|
656 |
+
except Exception as e:
|
657 |
+
logger.error(f"Помилка при завантаженні даних з CSV: {e}")
|
658 |
+
return False
|
659 |
+
|
660 |
+
def save_indices(self, indices_dir=None):
|
661 |
+
"""Публічний метод для збереження індексів."""
|
662 |
+
if indices_dir is None and self.indices_dir is not None:
|
663 |
+
indices_dir = self.indices_dir
|
664 |
+
|
665 |
+
if indices_dir:
|
666 |
+
return self._persist_indices_to_disk(indices_dir)
|
667 |
+
else:
|
668 |
+
logger.error("Не вказано директорію для збереження індексів")
|
669 |
+
return False
|
modules/ai_analysis/jira_qa_assistant.py
ADDED
@@ -0,0 +1,418 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import logging
|
3 |
+
import tiktoken
|
4 |
+
from datetime import datetime
|
5 |
+
from pathlib import Path
|
6 |
+
from typing import List, Dict, Any, Optional
|
7 |
+
|
8 |
+
from modules.config.ai_settings import DEFAULT_EMBEDDING_MODEL, FALLBACK_EMBEDDING_MODEL
|
9 |
+
from prompts import system_prompt_qa_assistant
|
10 |
+
|
11 |
+
# Налаштування логування
|
12 |
+
logger = logging.getLogger(__name__)
|
13 |
+
|
14 |
+
# Імпорти з LlamaIndex
|
15 |
+
try:
|
16 |
+
from llama_index.core import Document
|
17 |
+
from llama_index.core.llms import ChatMessage
|
18 |
+
LLAMA_INDEX_AVAILABLE = True
|
19 |
+
except ImportError:
|
20 |
+
logger.warning("Не вдалося імпортувати LlamaIndex. Встановіть необхідні залежності для використання Q/A асистента.")
|
21 |
+
LLAMA_INDEX_AVAILABLE = False
|
22 |
+
|
23 |
+
class JiraQAAssistant:
|
24 |
+
"""
|
25 |
+
Клас асистента для режиму Q/A з повним контекстом для даних Jira.
|
26 |
+
Дозволяє задавати питання по всім документам Jira без використання пошукових індексів.
|
27 |
+
"""
|
28 |
+
def __init__(self, api_key_openai=None, api_key_gemini=None, model_type="gemini", temperature=0.2):
|
29 |
+
"""
|
30 |
+
Ініціалізація Q/A асистента.
|
31 |
+
|
32 |
+
Args:
|
33 |
+
api_key_openai (str): API ключ для OpenAI
|
34 |
+
api_key_gemini (str): API ключ для Google Gemini
|
35 |
+
model_type (str): Тип моделі ("openai" або "gemini")
|
36 |
+
temperature (float): Параметр температури для генерації відповідей
|
37 |
+
"""
|
38 |
+
self.model_type = model_type.lower()
|
39 |
+
self.temperature = temperature
|
40 |
+
self.api_key_openai = api_key_openai or os.getenv("OPENAI_API_KEY", "")
|
41 |
+
self.api_key_gemini = api_key_gemini or os.getenv("GEMINI_API_KEY", "")
|
42 |
+
|
43 |
+
# Перевірка наявності LlamaIndex
|
44 |
+
if not LLAMA_INDEX_AVAILABLE:
|
45 |
+
logger.error("LlamaIndex не доступний. Встановіть пакети: pip install llama-index-llms-gemini llama-index")
|
46 |
+
raise ImportError("LlamaIndex не встановлено. Необхідний для роботи Q/A асистента.")
|
47 |
+
|
48 |
+
# Ініціалізація моделі LLM
|
49 |
+
self.llm = None
|
50 |
+
|
51 |
+
# Дані Jira
|
52 |
+
self.df = None
|
53 |
+
self.jira_documents = []
|
54 |
+
|
55 |
+
# Ініціалізуємо модель LLM
|
56 |
+
self._initialize_llm()
|
57 |
+
|
58 |
+
def _initialize_llm(self):
|
59 |
+
"""Ініціалізує модель LLM відповідно до налаштувань."""
|
60 |
+
try:
|
61 |
+
# Ініціалізація LLM моделі
|
62 |
+
if self.model_type == "gemini" and self.api_key_gemini:
|
63 |
+
os.environ["GEMINI_API_KEY"] = self.api_key_gemini
|
64 |
+
|
65 |
+
from llama_index.llms.gemini import Gemini
|
66 |
+
self.llm = Gemini(
|
67 |
+
model="models/gemini-2.0-flash",
|
68 |
+
temperature=self.temperature,
|
69 |
+
max_tokens=4096,
|
70 |
+
)
|
71 |
+
logger.info("Успішно ініціалізовано Gemini 2.0 Flash модель")
|
72 |
+
elif self.model_type == "openai" and self.api_key_openai:
|
73 |
+
os.environ["OPENAI_API_KEY"] = self.api_key_openai
|
74 |
+
|
75 |
+
from llama_index.llms.openai import OpenAI
|
76 |
+
self.llm = OpenAI(
|
77 |
+
model="gpt-4o-mini",
|
78 |
+
temperature=self.temperature,
|
79 |
+
max_tokens=4096
|
80 |
+
)
|
81 |
+
logger.info("Успішно ініціалізовано OpenAI GPT-4o-mini модель")
|
82 |
+
else:
|
83 |
+
error_msg = f"Не вдалося ініціалізувати LLM модель типу {self.model_type}. Перевірте API ключі."
|
84 |
+
logger.error(error_msg)
|
85 |
+
raise ValueError(error_msg)
|
86 |
+
|
87 |
+
except Exception as e:
|
88 |
+
logger.error(f"Помилка ініціалізації моделі LLM: {e}")
|
89 |
+
raise
|
90 |
+
|
91 |
+
def load_documents_from_dataframe(self, df):
|
92 |
+
"""
|
93 |
+
Завантаження документів прямо з DataFrame без створення індексів.
|
94 |
+
|
95 |
+
Args:
|
96 |
+
df (pandas.DataFrame): DataFrame з даними Jira
|
97 |
+
|
98 |
+
Returns:
|
99 |
+
bool: True якщо дані успішно завантажено
|
100 |
+
"""
|
101 |
+
try:
|
102 |
+
logger.info("Завантаження даних з DataFrame для Q/A")
|
103 |
+
|
104 |
+
# Зберігаємо оригінальний DataFrame
|
105 |
+
self.df = df.copy()
|
106 |
+
|
107 |
+
# Конвертуємо дані в документи
|
108 |
+
self._convert_dataframe_to_documents()
|
109 |
+
|
110 |
+
return True
|
111 |
+
|
112 |
+
except Exception as e:
|
113 |
+
logger.error(f"Помилка при завантаженні даних з DataFrame: {e}")
|
114 |
+
return False
|
115 |
+
|
116 |
+
def _convert_dataframe_to_documents(self):
|
117 |
+
"""
|
118 |
+
Перетворює дані DataFrame в об'єкти Document для роботи з моделлю LLM.
|
119 |
+
"""
|
120 |
+
import pandas as pd
|
121 |
+
|
122 |
+
if self.df is None:
|
123 |
+
logger.error("Не вдалося створити документи: відсутні дані DataFrame")
|
124 |
+
return
|
125 |
+
|
126 |
+
logger.info("Перетворення даних DataFrame в документи для Q/A...")
|
127 |
+
|
128 |
+
self.jira_documents = []
|
129 |
+
|
130 |
+
for idx, row in self.df.iterrows():
|
131 |
+
# Основний текст - опис тікета
|
132 |
+
text = ""
|
133 |
+
if 'Description' in row and pd.notna(row['Description']):
|
134 |
+
text = str(row['Description'])
|
135 |
+
|
136 |
+
# Додавання коментарів, якщо вони є
|
137 |
+
for col in self.df.columns:
|
138 |
+
if col.startswith('Comment') and pd.notna(row[col]):
|
139 |
+
text += f"\n\nКоментар: {str(row[col])}"
|
140 |
+
|
141 |
+
# Метадані для документа
|
142 |
+
metadata = {
|
143 |
+
"issue_key": row['Issue key'] if 'Issue key' in row and pd.notna(row['Issue key']) else "",
|
144 |
+
"issue_type": row['Issue Type'] if 'Issue Type' in row and pd.notna(row['Issue Type']) else "",
|
145 |
+
"status": row['Status'] if 'Status' in row and pd.notna(row['Status']) else "",
|
146 |
+
"priority": row['Priority'] if 'Priority' in row and pd.notna(row['Priority']) else "",
|
147 |
+
"assignee": row['Assignee'] if 'Assignee' in row and pd.notna(row['Assignee']) else "",
|
148 |
+
"reporter": row['Reporter'] if 'Reporter' in row and pd.notna(row['Reporter']) else "",
|
149 |
+
"created": str(row['Created']) if 'Created' in row and pd.notna(row['Created']) else "",
|
150 |
+
"updated": str(row['Updated']) if 'Updated' in row and pd.notna(row['Updated']) else "",
|
151 |
+
"summary": row['Summary'] if 'Summary' in row and pd.notna(row['Summary']) else "",
|
152 |
+
"project": row['Project name'] if 'Project name' in row and pd.notna(row['Project name']) else ""
|
153 |
+
}
|
154 |
+
|
155 |
+
# Додатково перевіряємо поле зв'язків, якщо воно є
|
156 |
+
if 'Outward issue link (Relates)' in row and pd.notna(row['Outward issue link (Relates)']):
|
157 |
+
metadata["related_issues"] = row['Outward issue link (Relates)']
|
158 |
+
|
159 |
+
# Додатково перевіряємо інші можливі поля зв'язків
|
160 |
+
for col in self.df.columns:
|
161 |
+
if col.startswith('Outward issue link') and col != 'Outward issue link (Relates)' and pd.notna(row[col]):
|
162 |
+
link_type = col.replace('Outward issue link ', '').strip('()')
|
163 |
+
if "links" not in metadata:
|
164 |
+
metadata["links"] = {}
|
165 |
+
metadata["links"][link_type] = str(row[col])
|
166 |
+
|
167 |
+
# Створення документа
|
168 |
+
doc = Document(
|
169 |
+
text=text,
|
170 |
+
metadata=metadata
|
171 |
+
)
|
172 |
+
|
173 |
+
self.jira_documents.append(doc)
|
174 |
+
|
175 |
+
logger.info(f"Створено {len(self.jira_documents)} документів для Q/A")
|
176 |
+
|
177 |
+
def _count_tokens(self, text: str, model: str = "gpt-3.5-turbo") -> int:
|
178 |
+
"""
|
179 |
+
Підраховує приблизну кількість токенів для тексту.
|
180 |
+
|
181 |
+
Args:
|
182 |
+
text (str): Текст для підрахунку токенів
|
183 |
+
model (str): Назва моделі для вибору енкодера
|
184 |
+
|
185 |
+
Returns:
|
186 |
+
int: Кількість токенів
|
187 |
+
"""
|
188 |
+
try:
|
189 |
+
encoding = tiktoken.encoding_for_model(model)
|
190 |
+
tokens = encoding.encode(text)
|
191 |
+
return len(tokens)
|
192 |
+
except Exception as e:
|
193 |
+
logger.warning(f"Не вдалося підрахувати токени через tiktoken: {e}")
|
194 |
+
# Якщо не можемо використати tiktoken, робимо просту оцінку
|
195 |
+
# В середньому 1 токен ≈ 3 символи для змішаного тексту
|
196 |
+
return len(text) // 3 # Приблизна оцінка
|
197 |
+
|
198 |
+
def run_qa(self, question: str) -> Dict[str, Any]:
|
199 |
+
"""
|
200 |
+
Запускає режим Q/A з повним контекстом.
|
201 |
+
|
202 |
+
Args:
|
203 |
+
question (str): Питання користувача
|
204 |
+
|
205 |
+
Returns:
|
206 |
+
Dict[str, Any]: Словник з результатами, включаючи відповідь та метадані
|
207 |
+
"""
|
208 |
+
if not self.jira_documents or not self.llm:
|
209 |
+
error_msg = "Не вдалося виконати запит: відсутні документи або LLM"
|
210 |
+
logger.error(error_msg)
|
211 |
+
return {"error": error_msg}
|
212 |
+
|
213 |
+
try:
|
214 |
+
logger.info(f"Запуск режиму Q/A з повним контекстом для питання: {question}")
|
215 |
+
|
216 |
+
# Підготовка повного контексту з усіх документів
|
217 |
+
full_context = "ПОВНИЙ КОНТЕКСТ JIRA ТІКЕТІВ:\n\n"
|
218 |
+
|
219 |
+
# Додаємо статистику по тікетах
|
220 |
+
status_counts = {}
|
221 |
+
type_counts = {}
|
222 |
+
priority_counts = {}
|
223 |
+
assignee_counts = {}
|
224 |
+
|
225 |
+
for doc in self.jira_documents:
|
226 |
+
status = doc.metadata.get("status", "")
|
227 |
+
issue_type = doc.metadata.get("issue_type", "")
|
228 |
+
priority = doc.metadata.get("priority", "")
|
229 |
+
assignee = doc.metadata.get("assignee", "")
|
230 |
+
|
231 |
+
if status:
|
232 |
+
status_counts[status] = status_counts.get(status, 0) + 1
|
233 |
+
if issue_type:
|
234 |
+
type_counts[issue_type] = type_counts.get(issue_type, 0) + 1
|
235 |
+
if priority:
|
236 |
+
priority_counts[priority] = priority_counts.get(priority, 0) + 1
|
237 |
+
if assignee:
|
238 |
+
assignee_counts[assignee] = assignee_counts.get(assignee, 0) + 1
|
239 |
+
|
240 |
+
# Додаємо статистику до контексту
|
241 |
+
full_context += f"Всього тікетів: {len(self.jira_documents)}\n\n"
|
242 |
+
|
243 |
+
full_context += "Статуси:\n"
|
244 |
+
for status, count in sorted(status_counts.items(), key=lambda x: x[1], reverse=True):
|
245 |
+
full_context += f"- {status}: {count}\n"
|
246 |
+
|
247 |
+
full_context += "\nТипи тікетів:\n"
|
248 |
+
for issue_type, count in sorted(type_counts.items(), key=lambda x: x[1], reverse=True):
|
249 |
+
full_context += f"- {issue_type}: {count}\n"
|
250 |
+
|
251 |
+
full_context += "\nПріоритети:\n"
|
252 |
+
for priority, count in sorted(priority_counts.items(), key=lambda x: x[1], reverse=True):
|
253 |
+
full_context += f"- {priority}: {count}\n"
|
254 |
+
|
255 |
+
# Додаємо топ-5 виконавців
|
256 |
+
if assignee_counts:
|
257 |
+
full_context += "\nТоп виконавців:\n"
|
258 |
+
for assignee, count in sorted(assignee_counts.items(), key=lambda x: x[1], reverse=True)[:5]:
|
259 |
+
full_context += f"- {assignee}: {count} тікетів\n"
|
260 |
+
|
261 |
+
# Додаємо всі тікети з метаданими та текстом
|
262 |
+
full_context += "\nДЕТАЛЬНА ІНФОРМАЦІЯ ПРО ТІКЕТИ:\n\n"
|
263 |
+
|
264 |
+
for i, doc in enumerate(self.jira_documents):
|
265 |
+
# Використовуємо ключ тікета, якщо доступний, інакше номер
|
266 |
+
ticket_id = doc.metadata.get("issue_key", f"TICKET-{i+1}")
|
267 |
+
summary = doc.metadata.get("summary", "Без опису")
|
268 |
+
|
269 |
+
full_context += f"ТІКЕТ {ticket_id}: {summary}\n"
|
270 |
+
|
271 |
+
# Додаємо всі метадані
|
272 |
+
for key, value in doc.metadata.items():
|
273 |
+
# Пропускаємо виключені поля та вже виведені поля
|
274 |
+
if key == "project" or key == "summary" or key == "issue_key":
|
275 |
+
continue
|
276 |
+
|
277 |
+
if isinstance(value, dict):
|
278 |
+
# Обробка вкладених словників (наприклад, links)
|
279 |
+
full_context += f"{key}:\n"
|
280 |
+
for sub_key, sub_value in value.items():
|
281 |
+
if sub_value:
|
282 |
+
full_context += f" - {sub_key}: {sub_value}\n"
|
283 |
+
elif value: # Додаємо тільки непорожні значення
|
284 |
+
full_context += f"{key}: {value}\n"
|
285 |
+
|
286 |
+
# Додаємо текст документа
|
287 |
+
if doc.text:
|
288 |
+
# Якщо текст дуже довгий, обмежуємо його для економії токенів
|
289 |
+
if len(doc.text) > 1000:
|
290 |
+
truncated_text = doc.text[:1000] + "... [текст скорочено]"
|
291 |
+
full_context += f"Опис: {truncated_text}\n"
|
292 |
+
else:
|
293 |
+
full_context += f"Опис: {doc.text}\n"
|
294 |
+
|
295 |
+
full_context += "\n" + "-"*40 + "\n\n"
|
296 |
+
|
297 |
+
# Підрахуємо токени для по��ного контексту
|
298 |
+
full_context_tokens = self._count_tokens(full_context)
|
299 |
+
logger.info(f"Приблизна кількість токенів у повному контексті: {full_context_tokens}")
|
300 |
+
|
301 |
+
# Перевірка на перевищення ліміту токенів (для Gemini 2.0 Flash - 1,048,576 вхідних токенів)
|
302 |
+
max_input_tokens = 1048576
|
303 |
+
if full_context_tokens > max_input_tokens:
|
304 |
+
logger.warning(f"Контекст перевищує ліміт вхідних токенів моделі ({full_context_tokens} > {max_input_tokens}).")
|
305 |
+
logger.info("Виконується скорочення контексту...")
|
306 |
+
|
307 |
+
# Обчислюємо, скільки можна включити тікетів
|
308 |
+
tokens_per_ticket = full_context_tokens / len(self.jira_documents)
|
309 |
+
safe_ticket_count = int(max_input_tokens * 0.8 / tokens_per_ticket) # 80% від ліміту для безпеки
|
310 |
+
|
311 |
+
# Обчислюємо новий контекст з меншою кількістю тікетів
|
312 |
+
full_context = full_context.split("ДЕТАЛЬНА ІНФОРМАЦІЯ ПРО ТІКЕТИ:")[0]
|
313 |
+
full_context += "\nДЕТАЛЬНА ІНФОРМАЦІЯ ПРО ТІКЕТИ (скорочено):\n\n"
|
314 |
+
|
315 |
+
for i, doc in enumerate(self.jira_documents[:safe_ticket_count]):
|
316 |
+
ticket_id = doc.metadata.get("issue_key", f"TICKET-{i+1}")
|
317 |
+
summary = doc.metadata.get("summary", "Без опису")
|
318 |
+
|
319 |
+
full_context += f"ТІКЕТ {ticket_id}: {summary}\n"
|
320 |
+
|
321 |
+
# Додаємо найважливіші метадані
|
322 |
+
important_fields = ["status", "priority", "assignee", "created", "updated"]
|
323 |
+
for key in important_fields:
|
324 |
+
value = doc.metadata.get(key, "")
|
325 |
+
if value:
|
326 |
+
full_context += f"{key}: {value}\n"
|
327 |
+
|
328 |
+
# Додаємо скорочений опис
|
329 |
+
if doc.text:
|
330 |
+
short_text = doc.text[:300] + "..." if len(doc.text) > 300 else doc.text
|
331 |
+
full_context += f"Опис: {short_text}\n"
|
332 |
+
|
333 |
+
full_context += "\n" + "-"*30 + "\n\n"
|
334 |
+
|
335 |
+
full_context += f"\n[Показано {safe_ticket_count} з {len(self.jira_documents)} тікетів через обмеження контексту]\n"
|
336 |
+
|
337 |
+
# Перераховуємо токени для скороченого контексту
|
338 |
+
full_context_tokens = self._count_tokens(full_context)
|
339 |
+
logger.info(f"Скорочений контекст: {full_context_tokens} токенів")
|
340 |
+
|
341 |
+
# Системний промпт для режиму Q/A
|
342 |
+
system_prompt = system_prompt_qa_assistant
|
343 |
+
|
344 |
+
# Підрахунок токенів для питання
|
345 |
+
question_tokens = self._count_tokens(question)
|
346 |
+
|
347 |
+
# Формуємо повідомлення для чату
|
348 |
+
messages = [
|
349 |
+
ChatMessage(role="system", content=system_prompt),
|
350 |
+
ChatMessage(role="system", content=full_context),
|
351 |
+
ChatMessage(role="user", content=question)
|
352 |
+
]
|
353 |
+
|
354 |
+
# Отримуємо відповідь від LLM
|
355 |
+
logger.info("Генерація відповіді...")
|
356 |
+
response = self.llm.chat(messages)
|
357 |
+
|
358 |
+
# Підрахунок токенів для відповіді
|
359 |
+
response_text = str(response)
|
360 |
+
response_tokens = self._count_tokens(response_text)
|
361 |
+
|
362 |
+
logger.info(f"Відповідь успішно згенеровано, токенів: {response_tokens}")
|
363 |
+
|
364 |
+
return {
|
365 |
+
"answer": response_text,
|
366 |
+
"metadata": {
|
367 |
+
"question_tokens": question_tokens,
|
368 |
+
"context_tokens": full_context_tokens,
|
369 |
+
"response_tokens": response_tokens,
|
370 |
+
"total_tokens": question_tokens + full_context_tokens + response_tokens,
|
371 |
+
"documents_used": len(self.jira_documents)
|
372 |
+
}
|
373 |
+
}
|
374 |
+
|
375 |
+
except Exception as e:
|
376 |
+
error_msg = f"Помилка при виконанні Q/A з повним контекстом: {e}"
|
377 |
+
logger.error(error_msg)
|
378 |
+
return {"error": error_msg}
|
379 |
+
|
380 |
+
def get_statistics(self) -> Dict[str, Any]:
|
381 |
+
"""
|
382 |
+
Повертає загальну статистику за документами.
|
383 |
+
|
384 |
+
Returns:
|
385 |
+
Dict[str, Any]: Словник зі статистикою
|
386 |
+
"""
|
387 |
+
if not self.jira_documents:
|
388 |
+
return {"error": "Немає завантажених документів"}
|
389 |
+
|
390 |
+
# Статистика по тікетах
|
391 |
+
status_counts = {}
|
392 |
+
type_counts = {}
|
393 |
+
priority_counts = {}
|
394 |
+
assignee_counts = {}
|
395 |
+
|
396 |
+
for doc in self.jira_documents:
|
397 |
+
status = doc.metadata.get("status", "")
|
398 |
+
issue_type = doc.metadata.get("issue_type", "")
|
399 |
+
priority = doc.metadata.get("priority", "")
|
400 |
+
assignee = doc.metadata.get("assignee", "")
|
401 |
+
|
402 |
+
if status:
|
403 |
+
status_counts[status] = status_counts.get(status, 0) + 1
|
404 |
+
if issue_type:
|
405 |
+
type_counts[issue_type] = type_counts.get(issue_type, 0) + 1
|
406 |
+
if priority:
|
407 |
+
priority_counts[priority] = priority_counts.get(priority, 0) + 1
|
408 |
+
if assignee:
|
409 |
+
assignee_counts[assignee] = assignee_counts.get(assignee, 0) + 1
|
410 |
+
|
411 |
+
# Формуємо результат
|
412 |
+
return {
|
413 |
+
"document_count": len(self.jira_documents),
|
414 |
+
"status_counts": status_counts,
|
415 |
+
"type_counts": type_counts,
|
416 |
+
"priority_counts": priority_counts,
|
417 |
+
"top_assignees": dict(sorted(assignee_counts.items(), key=lambda x: x[1], reverse=True)[:5])
|
418 |
+
}
|
modules/config/ai_settings.py
ADDED
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import pandas as pd
|
2 |
+
|
3 |
+
|
4 |
+
# Налаштування Google Embeddings
|
5 |
+
GOOGLE_EMBEDDING_MODEL = "text-embedding-004" # Модель Google Embeddings
|
6 |
+
USE_GOOGLE_EMBEDDINGS = True # Прапорець для вмикання/вимикання Google Embeddings
|
7 |
+
|
8 |
+
|
9 |
+
# Налаштування для моделей ембедингів
|
10 |
+
DEFAULT_EMBEDDING_MODEL = "paraphrase-multilingual-MiniLM-L12-v2"
|
11 |
+
FALLBACK_EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
12 |
+
|
13 |
+
# Налаштування для індексування
|
14 |
+
CHUNK_SIZE = 2048 # Розмір чанка для розбиття документів
|
15 |
+
CHUNK_OVERLAP = 128 # Перекриття між чанками
|
16 |
+
SIMILARITY_TOP_K = 10 # Кількість найбільш релевантних документів для пошуку
|
17 |
+
|
18 |
+
# Додаткові налаштування для форсування CPU
|
19 |
+
FORCE_CPU = True
|
20 |
+
|
21 |
+
# Налаштування LLM
|
22 |
+
MAX_TOKENS = 8192
|
23 |
+
|
24 |
+
# Виключені метадані
|
25 |
+
EXCLUDED_EMBED_METADATA_KEYS = ["node_info", "project"] # Метадані, які не використовуються для ембедингів
|
26 |
+
EXCLUDED_LLM_METADATA_KEYS = ["node_info", "project"] # Метадані, які не передаються в LLM
|
27 |
+
|
28 |
+
# Налаштування для гібридного пошуку
|
29 |
+
HYBRID_SEARCH_MODE = "reciprocal_rerank" # Режим гібридного пошуку (reciprocal_rerank, simple)
|
30 |
+
|
31 |
+
def get_metadata_csv(row, idx):
|
32 |
+
return {
|
33 |
+
"issue_key": row.get('Issue key', '') if pd.notna(row.get('Issue key', '')) else "",
|
34 |
+
"issue_type": row.get('Issue Type', '') if pd.notna(row.get('Issue Type', '')) else "",
|
35 |
+
"status": row.get('Status', '') if pd.notna(row.get('Status', '')) else "",
|
36 |
+
"priority": row.get('Priority', '') if pd.notna(row.get('Priority', '')) else "",
|
37 |
+
"assignee": row.get('Assignee', '') if pd.notna(row.get('Assignee', '')) else "",
|
38 |
+
"reporter": row.get('Reporter', '') if pd.notna(row.get('Reporter', '')) else "",
|
39 |
+
"created": str(row.get('Created', '')) if pd.notna(row.get('Created', '')) else "",
|
40 |
+
"updated": str(row.get('Updated', '')) if pd.notna(row.get('Updated', '')) else "",
|
41 |
+
"summary": row.get('Summary', '') if pd.notna(row.get('Summary', '')) else "",
|
42 |
+
"project": row.get('Project name', '') if pd.notna(row.get('Project name', '')) else "",
|
43 |
+
"project_key": row.get('Project key', '') if pd.notna(row.get('Project key', '')) else "",
|
44 |
+
"labels": row.get('Labels', '') if pd.notna(row.get('Labels', '')) else "",
|
45 |
+
"comment": row.get('Comment', '') if pd.notna(row.get('Comment', '')) else "",
|
46 |
+
"row_index": idx
|
47 |
+
}
|
modules/config/logging_config.py
ADDED
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import logging
|
2 |
+
import os
|
3 |
+
from pathlib import Path
|
4 |
+
|
5 |
+
def setup_logging(log_dir="logs", log_level=logging.INFO):
|
6 |
+
"""
|
7 |
+
Налаштування логування.
|
8 |
+
|
9 |
+
Args:
|
10 |
+
log_dir (str): Директорія для зберігання логів
|
11 |
+
log_level (int): Рівень логування
|
12 |
+
"""
|
13 |
+
# Створюємо директорію для логів, якщо вона не існує
|
14 |
+
log_path = Path(log_dir)
|
15 |
+
log_path.mkdir(exist_ok=True, parents=True)
|
16 |
+
|
17 |
+
# Налаштовуємо формат логів
|
18 |
+
log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
19 |
+
date_format = "%Y-%m-%d %H:%M:%S"
|
20 |
+
|
21 |
+
# Налаштовуємо базовий логер
|
22 |
+
logging.basicConfig(
|
23 |
+
level=log_level,
|
24 |
+
format=log_format,
|
25 |
+
datefmt=date_format,
|
26 |
+
handlers=[
|
27 |
+
logging.FileHandler(log_path / "app.log", encoding="utf-8"),
|
28 |
+
logging.StreamHandler()
|
29 |
+
]
|
30 |
+
)
|
31 |
+
|
32 |
+
# Налаштовуємо окремий логер для індексів
|
33 |
+
index_logger = logging.getLogger("modules.data_management")
|
34 |
+
index_file_handler = logging.FileHandler(log_path / "indices.log", encoding="utf-8")
|
35 |
+
index_file_handler.setFormatter(logging.Formatter(log_format, date_format))
|
36 |
+
index_logger.addHandler(index_file_handler)
|
37 |
+
index_logger.setLevel(log_level)
|
38 |
+
|
39 |
+
# Налаштовуємо окремий логер для AI аналізу
|
40 |
+
ai_logger = logging.getLogger("modules.ai_analysis")
|
41 |
+
ai_file_handler = logging.FileHandler(log_path / "ai_analysis.log", encoding="utf-8")
|
42 |
+
ai_file_handler.setFormatter(logging.Formatter(log_format, date_format))
|
43 |
+
ai_logger.addHandler(ai_file_handler)
|
44 |
+
ai_logger.setLevel(log_level)
|
45 |
+
|
46 |
+
# Зменшуємо рівень логування для деяких бібліотек
|
47 |
+
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
48 |
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
49 |
+
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
50 |
+
|
51 |
+
# Логуємо початок роботи
|
52 |
+
logging.info("Логування налаштовано")
|
modules/config/paths.py
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
from pathlib import Path
|
3 |
+
|
4 |
+
# Базові директорії
|
5 |
+
BASE_DIR = Path(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
6 |
+
TEMP_DIR = BASE_DIR / "temp"
|
7 |
+
|
8 |
+
# Директорії для індексів
|
9 |
+
INDICES_DIR = TEMP_DIR / "indices"
|
modules/core/app_manager.py
ADDED
@@ -0,0 +1,648 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import logging
|
3 |
+
import pandas as pd
|
4 |
+
import json
|
5 |
+
from datetime import datetime
|
6 |
+
from pathlib import Path
|
7 |
+
import importlib
|
8 |
+
import requests
|
9 |
+
|
10 |
+
logger = logging.getLogger(__name__)
|
11 |
+
|
12 |
+
class AppManager:
|
13 |
+
"""
|
14 |
+
Класс, який керує роботою додатку Jira AI Assistant
|
15 |
+
"""
|
16 |
+
def __init__(self):
|
17 |
+
"""
|
18 |
+
Ініціалізація менеджера додатку
|
19 |
+
"""
|
20 |
+
self.config = self._load_config()
|
21 |
+
self.setup_logging()
|
22 |
+
self.data = None
|
23 |
+
self.analyses = {}
|
24 |
+
self.reports = {}
|
25 |
+
|
26 |
+
# Створення директорій для даних, якщо вони не існують
|
27 |
+
self._create_directories()
|
28 |
+
|
29 |
+
def _load_config(self):
|
30 |
+
"""
|
31 |
+
Завантаження конфігурації додатку
|
32 |
+
|
33 |
+
Returns:
|
34 |
+
dict: Конфігурація додатку
|
35 |
+
"""
|
36 |
+
try:
|
37 |
+
# Спочатку спробуємо завантажити з файлу
|
38 |
+
config_path = Path("config.json")
|
39 |
+
|
40 |
+
if config_path.exists():
|
41 |
+
with open(config_path, 'r', encoding='utf-8') as f:
|
42 |
+
config = json.load(f)
|
43 |
+
logger.info("Конфігурація завантажена з файлу")
|
44 |
+
return config
|
45 |
+
|
46 |
+
# Якщо файл не існує, використовуємо стандартну конфігурацію
|
47 |
+
config = {
|
48 |
+
"app_name": "Jira AI Assistant",
|
49 |
+
"version": "1.0.0",
|
50 |
+
"data_dir": "data",
|
51 |
+
"reports_dir": "reports",
|
52 |
+
"temp_dir": "temp",
|
53 |
+
"log_dir": "logs",
|
54 |
+
"log_level": "INFO",
|
55 |
+
"default_inactive_days": 14,
|
56 |
+
"openai_model": "gpt-3.5-turbo",
|
57 |
+
"gemini_model": "gemini-pro",
|
58 |
+
"max_results": 500
|
59 |
+
}
|
60 |
+
|
61 |
+
# Зберігаємо стандартну конфігурацію у файл
|
62 |
+
with open(config_path, 'w', encoding='utf-8') as f:
|
63 |
+
json.dump(config, f, indent=2)
|
64 |
+
|
65 |
+
logger.info("Створено стандартну конфігурацію")
|
66 |
+
return config
|
67 |
+
|
68 |
+
except Exception as e:
|
69 |
+
logger.error(f"Помилка при завантаженні конфігурації: {e}")
|
70 |
+
|
71 |
+
# Аварійна конфігурація
|
72 |
+
return {
|
73 |
+
"app_name": "Jira AI Assistant",
|
74 |
+
"version": "1.0.0",
|
75 |
+
"data_dir": "data",
|
76 |
+
"reports_dir": "reports",
|
77 |
+
"temp_dir": "temp",
|
78 |
+
"log_dir": "logs",
|
79 |
+
"log_level": "INFO",
|
80 |
+
"default_inactive_days": 14,
|
81 |
+
"openai_model": "gpt-3.5-turbo",
|
82 |
+
"gemini_model": "gemini-pro",
|
83 |
+
"max_results": 500
|
84 |
+
}
|
85 |
+
|
86 |
+
def setup_logging(self):
|
87 |
+
"""
|
88 |
+
Налаштування логування
|
89 |
+
"""
|
90 |
+
try:
|
91 |
+
log_dir = Path(self.config.get("log_dir", "logs"))
|
92 |
+
log_dir.mkdir(exist_ok=True, parents=True)
|
93 |
+
|
94 |
+
log_file = log_dir / f"app_{datetime.now().strftime('%Y%m%d')}.log"
|
95 |
+
|
96 |
+
# Рівень логування з конфігурації
|
97 |
+
log_level_str = self.config.get("log_level", "INFO")
|
98 |
+
log_level = getattr(logging, log_level_str, logging.INFO)
|
99 |
+
|
100 |
+
# Налаштування логера
|
101 |
+
logging.basicConfig(
|
102 |
+
level=log_level,
|
103 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
104 |
+
handlers=[
|
105 |
+
logging.FileHandler(log_file),
|
106 |
+
logging.StreamHandler()
|
107 |
+
]
|
108 |
+
)
|
109 |
+
|
110 |
+
logger.info(f"Логування налаштовано. Рівень: {log_level_str}, файл: {log_file}")
|
111 |
+
|
112 |
+
except Exception as e:
|
113 |
+
print(f"Помилка при налаштуванні логування: {e}")
|
114 |
+
|
115 |
+
# Аварійне налаштування логування
|
116 |
+
logging.basicConfig(
|
117 |
+
level=logging.INFO,
|
118 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
119 |
+
)
|
120 |
+
|
121 |
+
def _create_directories(self):
|
122 |
+
"""
|
123 |
+
Створення необхідних директорій
|
124 |
+
"""
|
125 |
+
try:
|
126 |
+
directories = [
|
127 |
+
self.config.get("data_dir", "data"),
|
128 |
+
self.config.get("reports_dir", "reports"),
|
129 |
+
self.config.get("temp_dir", "temp"),
|
130 |
+
self.config.get("log_dir", "logs")
|
131 |
+
]
|
132 |
+
|
133 |
+
for directory in directories:
|
134 |
+
Path(directory).mkdir(exist_ok=True, parents=True)
|
135 |
+
|
136 |
+
logger.info("Створено необхідні директорії")
|
137 |
+
|
138 |
+
except Exception as e:
|
139 |
+
logger.error(f"Помилка при створенні директорій: {e}")
|
140 |
+
|
141 |
+
def load_csv_data(self, file_path):
|
142 |
+
"""
|
143 |
+
Завантаження даних з CSV файлу
|
144 |
+
|
145 |
+
Args:
|
146 |
+
file_path (str): Шлях до CSV файлу
|
147 |
+
|
148 |
+
Returns:
|
149 |
+
pandas.DataFrame: Завантажені дані або None у випадку помилки
|
150 |
+
"""
|
151 |
+
try:
|
152 |
+
logger.info(f"Завантаження даних з CSV файлу: {file_path}")
|
153 |
+
|
154 |
+
# Імпортуємо необхідний модуль
|
155 |
+
from modules.data_import.csv_importer import JiraCsvImporter
|
156 |
+
|
157 |
+
# Створюємо імпортер та завантажуємо дані
|
158 |
+
importer = JiraCsvImporter(file_path)
|
159 |
+
self.data = importer.load_data()
|
160 |
+
|
161 |
+
if self.data is None:
|
162 |
+
logger.error("Не вдалося завантажити дані з CSV файлу")
|
163 |
+
return None
|
164 |
+
|
165 |
+
logger.info(f"Успішно завантажено {len(self.data)} записів")
|
166 |
+
|
167 |
+
# Зберігаємо копію даних
|
168 |
+
self._save_data_copy(file_path)
|
169 |
+
|
170 |
+
return self.data
|
171 |
+
|
172 |
+
except Exception as e:
|
173 |
+
logger.error(f"Помилка при завантаженні даних з CSV: {e}")
|
174 |
+
return None
|
175 |
+
|
176 |
+
def _save_data_copy(self, original_file_path):
|
177 |
+
"""
|
178 |
+
Збереження копії даних
|
179 |
+
|
180 |
+
Args:
|
181 |
+
original_file_path (str): Шлях до оригінального файлу
|
182 |
+
"""
|
183 |
+
try:
|
184 |
+
if self.data is None:
|
185 |
+
return
|
186 |
+
|
187 |
+
# Створюємо ім'я файлу на основі оригінального
|
188 |
+
file_name = os.path.basename(original_file_path)
|
189 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
190 |
+
new_file_name = f"{os.path.splitext(file_name)[0]}_{timestamp}.csv"
|
191 |
+
|
192 |
+
# Шлях для збереження
|
193 |
+
data_dir = Path(self.config.get("data_dir", "data"))
|
194 |
+
save_path = data_dir / new_file_name
|
195 |
+
|
196 |
+
# Зберігаємо дані
|
197 |
+
self.data.to_csv(save_path, index=False, encoding='utf-8')
|
198 |
+
|
199 |
+
logger.info(f"Збережено копію даних у {save_path}")
|
200 |
+
|
201 |
+
except Exception as e:
|
202 |
+
logger.error(f"Помилка при збереженні копії даних: {e}")
|
203 |
+
|
204 |
+
def connect_to_jira(self, jira_url, username, api_token):
|
205 |
+
"""
|
206 |
+
Підключення до Jira API
|
207 |
+
|
208 |
+
Args:
|
209 |
+
jira_url (str): URL Jira сервера
|
210 |
+
username (str): Ім'я користувача
|
211 |
+
api_token (str): API токен
|
212 |
+
|
213 |
+
Returns:
|
214 |
+
bool: True, якщо підключення успішне, False у іншому випадку
|
215 |
+
"""
|
216 |
+
try:
|
217 |
+
logger.info(f"Тестування підключення до Jira: {jira_url}")
|
218 |
+
|
219 |
+
# Спроба прямого HTTP запиту до сервера
|
220 |
+
response = requests.get(
|
221 |
+
f"{jira_url}/rest/api/2/serverInfo",
|
222 |
+
auth=(username, api_token),
|
223 |
+
timeout=10,
|
224 |
+
verify=True
|
225 |
+
)
|
226 |
+
|
227 |
+
if response.status_code == 200:
|
228 |
+
logger.info("Успішне підключення до Jira API")
|
229 |
+
|
230 |
+
# Зберігаємо дані про підключення
|
231 |
+
self.jira_connection = {
|
232 |
+
"url": jira_url,
|
233 |
+
"username": username,
|
234 |
+
"api_token": api_token
|
235 |
+
}
|
236 |
+
|
237 |
+
return True
|
238 |
+
else:
|
239 |
+
logger.error(f"Помилка підключення до Jira: {response.status_code}, {response.text}")
|
240 |
+
return False
|
241 |
+
|
242 |
+
except Exception as e:
|
243 |
+
logger.error(f"Помилка при підключенні до Jira: {e}")
|
244 |
+
return False
|
245 |
+
|
246 |
+
def get_jira_data(self, project_key, board_id=None, max_results=None):
|
247 |
+
"""
|
248 |
+
Отримання даних з Jira API
|
249 |
+
|
250 |
+
Args:
|
251 |
+
project_key (str): Ключ проекту
|
252 |
+
board_id (int): ID дошки (необов'язково)
|
253 |
+
max_results (int): Максимальна кількість результатів
|
254 |
+
|
255 |
+
Returns:
|
256 |
+
pandas.DataFrame: Отримані дані або None у випадку помилки
|
257 |
+
"""
|
258 |
+
try:
|
259 |
+
if not hasattr(self, 'jira_connection'):
|
260 |
+
logger.error("Немає з'єднання з Jira")
|
261 |
+
return None
|
262 |
+
|
263 |
+
logger.info(f"Отримання даних з Jira для проекту {project_key}")
|
264 |
+
|
265 |
+
# Імпортуємо необхідний модуль
|
266 |
+
from modules.data_import.jira_api import JiraConnector
|
267 |
+
|
268 |
+
# Параметри з'єднання
|
269 |
+
jira_url = self.jira_connection["url"]
|
270 |
+
username = self.jira_connection["username"]
|
271 |
+
api_token = self.jira_connection["api_token"]
|
272 |
+
|
273 |
+
# Створюємо коннектор
|
274 |
+
connector = JiraConnector(jira_url, username, api_token)
|
275 |
+
|
276 |
+
# Отримуємо дані
|
277 |
+
if board_id:
|
278 |
+
issues = connector.get_board_issues(
|
279 |
+
board_id,
|
280 |
+
project_key,
|
281 |
+
max_results=max_results or self.config.get("max_results", 500)
|
282 |
+
)
|
283 |
+
else:
|
284 |
+
issues = connector.get_project_issues(
|
285 |
+
project_key,
|
286 |
+
max_results=max_results or self.config.get("max_results", 500)
|
287 |
+
)
|
288 |
+
|
289 |
+
if not issues:
|
290 |
+
logger.error("Не вдалося отримати тікети з Jira")
|
291 |
+
return None
|
292 |
+
|
293 |
+
# Експортуємо у CSV та завантажуємо дані
|
294 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
295 |
+
temp_dir = Path(self.config.get("temp_dir", "temp"))
|
296 |
+
temp_csv_path = temp_dir / f"jira_export_{project_key}_{timestamp}.csv"
|
297 |
+
|
298 |
+
df = connector.export_issues_to_csv(issues, temp_csv_path)
|
299 |
+
self.data = df
|
300 |
+
|
301 |
+
logger.info(f"Успішно отримано {len(df)} тікетів з Jira")
|
302 |
+
return df
|
303 |
+
|
304 |
+
except Exception as e:
|
305 |
+
logger.error(f"Помилка при отриманні даних з Jira: {e}")
|
306 |
+
return None
|
307 |
+
|
308 |
+
def analyze_data(self, inactive_days=None):
|
309 |
+
"""
|
310 |
+
Аналіз завантажених даних
|
311 |
+
|
312 |
+
Args:
|
313 |
+
inactive_days (int): Кількість днів для визначення неактивних тікетів
|
314 |
+
|
315 |
+
Returns:
|
316 |
+
dict: Результати аналізу
|
317 |
+
"""
|
318 |
+
try:
|
319 |
+
if self.data is None:
|
320 |
+
logger.error("Немає даних для аналізу")
|
321 |
+
return None
|
322 |
+
|
323 |
+
logger.info("Аналіз даних...")
|
324 |
+
|
325 |
+
# Параметри аналізу
|
326 |
+
if inactive_days is None:
|
327 |
+
inactive_days = self.config.get("default_inactive_days", 14)
|
328 |
+
|
329 |
+
# Імпортуємо необхідний модуль
|
330 |
+
from modules.data_analysis.statistics import JiraDataAnalyzer
|
331 |
+
|
332 |
+
# Створюємо аналізатор та виконуємо аналіз
|
333 |
+
analyzer = JiraDataAnalyzer(self.data)
|
334 |
+
|
335 |
+
# Генеруємо базову статистику
|
336 |
+
stats = analyzer.generate_basic_statistics()
|
337 |
+
|
338 |
+
# Аналізуємо неактивні тікети
|
339 |
+
inactive_issues = analyzer.analyze_inactive_issues(days=inactive_days)
|
340 |
+
|
341 |
+
# Аналізуємо часову шкалу
|
342 |
+
timeline = analyzer.analyze_timeline()
|
343 |
+
|
344 |
+
# Аналізуємо час виконання
|
345 |
+
lead_time = analyzer.analyze_lead_time()
|
346 |
+
|
347 |
+
# Зберігаємо результати аналізу
|
348 |
+
analysis_result = {
|
349 |
+
"stats": stats,
|
350 |
+
"inactive_issues": inactive_issues,
|
351 |
+
"timeline": timeline.to_dict() if isinstance(timeline, pd.DataFrame) else None,
|
352 |
+
"lead_time": lead_time
|
353 |
+
}
|
354 |
+
|
355 |
+
# Зберігаємо в історії аналізів
|
356 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
357 |
+
self.analyses[timestamp] = analysis_result
|
358 |
+
|
359 |
+
logger.info("Аналіз даних успішно завершено")
|
360 |
+
return analysis_result
|
361 |
+
|
362 |
+
except Exception as e:
|
363 |
+
logger.error(f"Помилка при аналізі даних: {e}")
|
364 |
+
return None
|
365 |
+
|
366 |
+
def generate_visualizations(self, analysis_result=None):
|
367 |
+
"""
|
368 |
+
Генерація візуалізацій на основі аналізу
|
369 |
+
|
370 |
+
Args:
|
371 |
+
analysis_result (dict): Результати аналізу або None для використання останнього аналізу
|
372 |
+
|
373 |
+
Returns:
|
374 |
+
dict: Об'єкти Figure для різних візуалізацій
|
375 |
+
"""
|
376 |
+
try:
|
377 |
+
if self.data is None:
|
378 |
+
logger.error("Немає даних для візуалізації")
|
379 |
+
return None
|
380 |
+
|
381 |
+
# Якщо аналіз не вказано, використовуємо останній
|
382 |
+
if analysis_result is None:
|
383 |
+
if not self.analyses:
|
384 |
+
logger.error("Немає результатів аналізу для візуалізації")
|
385 |
+
return None
|
386 |
+
|
387 |
+
# Отримуємо останній аналіз
|
388 |
+
last_timestamp = max(self.analyses.keys())
|
389 |
+
analysis_result = self.analyses[last_timestamp]
|
390 |
+
|
391 |
+
logger.info("Генерація візуалізацій...")
|
392 |
+
|
393 |
+
# Імпортуємо необхідний модуль
|
394 |
+
from modules.data_analysis.visualizations import JiraVisualizer
|
395 |
+
|
396 |
+
# Створюємо візуалізатор
|
397 |
+
visualizer = JiraVisualizer(self.data)
|
398 |
+
|
399 |
+
# Генеруємо візуалізації
|
400 |
+
visualizations = visualizer.plot_all()
|
401 |
+
|
402 |
+
logger.info("Візуалізації успішно згенеровано")
|
403 |
+
return visualizations
|
404 |
+
|
405 |
+
except Exception as e:
|
406 |
+
logger.error(f"Помилка при генерації візуалізацій: {e}")
|
407 |
+
return None
|
408 |
+
|
409 |
+
def analyze_with_ai(self, analysis_result=None, api_key=None, model_type="openai"):
|
410 |
+
"""
|
411 |
+
Аналіз даних за допомогою AI
|
412 |
+
|
413 |
+
Args:
|
414 |
+
analysis_result (dict): Результати аналізу або None для використання останнього аналізу
|
415 |
+
api_key (str): API ключ для LLM
|
416 |
+
model_type (str): Тип моделі ("openai" або "gemini")
|
417 |
+
|
418 |
+
Returns:
|
419 |
+
str: Результат AI аналізу
|
420 |
+
"""
|
421 |
+
try:
|
422 |
+
# Якщо аналіз не вказано, використовуємо останній
|
423 |
+
if analysis_result is None:
|
424 |
+
if not self.analyses:
|
425 |
+
logger.error("Немає результатів аналізу для AI")
|
426 |
+
return None
|
427 |
+
|
428 |
+
# Отримуємо останній аналіз
|
429 |
+
last_timestamp = max(self.analyses.keys())
|
430 |
+
analysis_result = self.analyses[last_timestamp]
|
431 |
+
|
432 |
+
logger.info(f"Аналіз даних за допомогою AI ({model_type})...")
|
433 |
+
|
434 |
+
# Імпортуємо необхідний модуль
|
435 |
+
from modules.ai_analysis.llm_connector import LLMConnector
|
436 |
+
|
437 |
+
# Створюємо коннектор до LLM
|
438 |
+
llm = LLMConnector(api_key=api_key, model_type=model_type)
|
439 |
+
|
440 |
+
# Виконуємо аналіз
|
441 |
+
stats = analysis_result.get("stats", {})
|
442 |
+
inactive_issues = analysis_result.get("inactive_issues", {})
|
443 |
+
|
444 |
+
ai_analysis = llm.analyze_jira_data(stats, inactive_issues)
|
445 |
+
|
446 |
+
logger.info("AI аналіз успішно завершено")
|
447 |
+
return ai_analysis
|
448 |
+
|
449 |
+
except Exception as e:
|
450 |
+
logger.error(f"Помилка при AI аналізі: {e}")
|
451 |
+
return f"Помилка при виконанні AI аналізу: {str(e)}"
|
452 |
+
|
453 |
+
def generate_report(self, analysis_result=None, ai_analysis=None, format="markdown", include_visualizations=True):
|
454 |
+
"""
|
455 |
+
Генерація звіту на основі аналізу
|
456 |
+
|
457 |
+
Args:
|
458 |
+
analysis_result (dict): Результати аналізу або None для використання останнього аналізу
|
459 |
+
ai_analysis (str): Результат AI аналізу
|
460 |
+
format (str): Формат звіту ("markdown", "html", "pdf")
|
461 |
+
include_visualizations (bool): Чи включати візуалізації у звіт
|
462 |
+
|
463 |
+
Returns:
|
464 |
+
str: Текст звіту
|
465 |
+
"""
|
466 |
+
try:
|
467 |
+
if self.data is None:
|
468 |
+
logger.error("Немає даних для генерації звіту")
|
469 |
+
return None
|
470 |
+
|
471 |
+
# Якщо аналіз не вказано, використовуємо останній
|
472 |
+
if analysis_result is None:
|
473 |
+
if not self.analyses:
|
474 |
+
logger.error("Немає результатів аналізу для звіту")
|
475 |
+
return None
|
476 |
+
|
477 |
+
# Отримуємо останній ана��із
|
478 |
+
last_timestamp = max(self.analyses.keys())
|
479 |
+
analysis_result = self.analyses[last_timestamp]
|
480 |
+
|
481 |
+
logger.info(f"Генерація звіту у форматі {format}...")
|
482 |
+
|
483 |
+
# Імпортуємо необхідний модуль
|
484 |
+
from modules.reporting.report_generator import ReportGenerator
|
485 |
+
|
486 |
+
# Отримуємо дані з аналізу
|
487 |
+
stats = analysis_result.get("stats", {})
|
488 |
+
inactive_issues = analysis_result.get("inactive_issues", {})
|
489 |
+
|
490 |
+
# Генеруємо візуалізації, якщо потрібно
|
491 |
+
visualization_data = None
|
492 |
+
if include_visualizations:
|
493 |
+
visualization_data = self.generate_visualizations(analysis_result)
|
494 |
+
|
495 |
+
# Створюємо генератор звітів
|
496 |
+
report_generator = ReportGenerator(self.data, stats, inactive_issues, ai_analysis)
|
497 |
+
|
498 |
+
# Генеруємо звіт у потрібному форматі
|
499 |
+
if format.lower() == "markdown":
|
500 |
+
report = report_generator.create_markdown_report()
|
501 |
+
elif format.lower() == "html":
|
502 |
+
report = report_generator.create_html_report(include_visualizations=include_visualizations,
|
503 |
+
visualization_data=visualization_data)
|
504 |
+
else:
|
505 |
+
# Для інших форматів спочатку генеруємо HTML
|
506 |
+
temp_html = report_generator.create_html_report(include_visualizations=include_visualizations,
|
507 |
+
visualization_data=visualization_data)
|
508 |
+
report = temp_html
|
509 |
+
|
510 |
+
# Зберігаємо звіт в історії
|
511 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
512 |
+
self.reports[timestamp] = {
|
513 |
+
"format": format,
|
514 |
+
"report": report
|
515 |
+
}
|
516 |
+
|
517 |
+
logger.info(f"Звіт успішно згенеровано у форматі {format}")
|
518 |
+
return report
|
519 |
+
|
520 |
+
except Exception as e:
|
521 |
+
logger.error(f"Помилка при генерації звіту: {e}")
|
522 |
+
return f"Помилка при генерації звіту: {str(e)}"
|
523 |
+
|
524 |
+
def save_report(self, report=None, filepath=None, format="markdown", include_visualizations=True):
|
525 |
+
"""
|
526 |
+
Збереження звіту у файл
|
527 |
+
|
528 |
+
Args:
|
529 |
+
report (str): Текст звіту або None для генерації нового
|
530 |
+
filepath (str): Шлях для збереження файлу
|
531 |
+
format (str): Формат звіту ("markdown", "html", "pdf")
|
532 |
+
include_visualizations (bool): Чи включати візуалізації у звіт
|
533 |
+
|
534 |
+
Returns:
|
535 |
+
str: Шлях до збереженого файлу
|
536 |
+
"""
|
537 |
+
try:
|
538 |
+
# Якщо звіт не вказано, генеруємо новий
|
539 |
+
if report is None:
|
540 |
+
report = self.generate_report(format=format, include_visualizations=include_visualizations)
|
541 |
+
|
542 |
+
if report is None:
|
543 |
+
logger.error("Не вдалося згенерувати звіт")
|
544 |
+
return None
|
545 |
+
|
546 |
+
# Якщо шлях не вказано, створюємо стандартний
|
547 |
+
if filepath is None:
|
548 |
+
reports_dir = Path(self.config.get("reports_dir", "reports"))
|
549 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
550 |
+
|
551 |
+
if format.lower() == "markdown":
|
552 |
+
filepath = reports_dir / f"jira_report_{timestamp}.md"
|
553 |
+
elif format.lower() == "html":
|
554 |
+
filepath = reports_dir / f"jira_report_{timestamp}.html"
|
555 |
+
else:
|
556 |
+
filepath = reports_dir / f"jira_report_{timestamp}.pdf"
|
557 |
+
|
558 |
+
# Імпортуємо необхідний модуль
|
559 |
+
from modules.reporting.report_generator import ReportGenerator
|
560 |
+
|
561 |
+
# Створюємо генератор звітів (лише для використання методу save_report)
|
562 |
+
report_generator = ReportGenerator(self.data)
|
563 |
+
|
564 |
+
# Генеруємо візуалізації, якщо потрібно
|
565 |
+
visualization_data = None
|
566 |
+
if include_visualizations:
|
567 |
+
visualization_data = self.generate_visualizations()
|
568 |
+
|
569 |
+
# Зберігаємо звіт
|
570 |
+
saved_path = report_generator.save_report(
|
571 |
+
filepath=str(filepath),
|
572 |
+
format=format,
|
573 |
+
include_visualizations=include_visualizations,
|
574 |
+
visualization_data=visualization_data
|
575 |
+
)
|
576 |
+
|
577 |
+
if saved_path:
|
578 |
+
logger.info(f"Звіт успішно збережено у {saved_path}")
|
579 |
+
return saved_path
|
580 |
+
else:
|
581 |
+
logger.error("Не вдалося зберегти звіт")
|
582 |
+
return None
|
583 |
+
|
584 |
+
except Exception as e:
|
585 |
+
logger.error(f"Помилка при збереженні звіту: {e}")
|
586 |
+
return None
|
587 |
+
|
588 |
+
def send_to_slack(self, channel, message, report=None, api_token=None):
|
589 |
+
"""
|
590 |
+
Відправлення повідомлення в Slack
|
591 |
+
|
592 |
+
Args:
|
593 |
+
channel (str): Назва каналу (наприклад, '#general')
|
594 |
+
message (str): Текст повідомлення
|
595 |
+
report (str): URL або шлях до звіту (необов'язково)
|
596 |
+
api_token (str): Slack Bot Token
|
597 |
+
|
598 |
+
Returns:
|
599 |
+
bool: True, якщо повідомлення успішно відправлено, False у іншому випадку
|
600 |
+
"""
|
601 |
+
try:
|
602 |
+
logger.info(f"Відправлення повідомлення в Slack канал {channel}...")
|
603 |
+
|
604 |
+
# Отримуємо токен
|
605 |
+
token = api_token or os.getenv("SLACK_BOT_TOKEN")
|
606 |
+
|
607 |
+
if not token:
|
608 |
+
logger.error("Не вказано Slack Bot Token")
|
609 |
+
return False
|
610 |
+
|
611 |
+
# Формуємо дані для запиту
|
612 |
+
slack_message = {
|
613 |
+
"channel": channel,
|
614 |
+
"text": message
|
615 |
+
}
|
616 |
+
|
617 |
+
# Якщо є звіт, додаємо його як вкладення
|
618 |
+
if report and report.startswith(("http://", "https://")):
|
619 |
+
slack_message["attachments"] = [
|
620 |
+
{
|
621 |
+
"title": "Звіт аналізу Jira",
|
622 |
+
"title_link": report,
|
623 |
+
"text": "Завантажити звіт"
|
624 |
+
}
|
625 |
+
]
|
626 |
+
|
627 |
+
# Відправляємо запит до Slack API
|
628 |
+
headers = {
|
629 |
+
"Authorization": f"Bearer {token}",
|
630 |
+
"Content-Type": "application/json"
|
631 |
+
}
|
632 |
+
|
633 |
+
response = requests.post(
|
634 |
+
"https://slack.com/api/chat.postMessage",
|
635 |
+
headers=headers,
|
636 |
+
json=slack_message
|
637 |
+
)
|
638 |
+
|
639 |
+
if response.status_code == 200 and response.json().get("ok"):
|
640 |
+
logger.info("Повідомлення успішно відправлено в Slack")
|
641 |
+
return True
|
642 |
+
else:
|
643 |
+
logger.error(f"Помилка при відправленні повідомлення в Slack: {response.text}")
|
644 |
+
return False
|
645 |
+
|
646 |
+
except Exception as e:
|
647 |
+
logger.error(f"Помилка при відправленні повідомлення в Slack: {e}")
|
648 |
+
return False
|
modules/data_analysis/statistics.py
ADDED
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import pandas as pd
|
2 |
+
import numpy as np
|
3 |
+
from datetime import datetime, timedelta
|
4 |
+
import logging
|
5 |
+
|
6 |
+
from modules.data_management.data_manager import safe_strftime
|
7 |
+
|
8 |
+
logger = logging.getLogger(__name__)
|
9 |
+
|
10 |
+
class JiraDataAnalyzer:
|
11 |
+
"""
|
12 |
+
Клас для аналізу даних Jira
|
13 |
+
"""
|
14 |
+
def __init__(self, df):
|
15 |
+
"""
|
16 |
+
Ініціалізація аналізатора даних.
|
17 |
+
|
18 |
+
Args:
|
19 |
+
df (pandas.DataFrame): DataFrame з даними Jira
|
20 |
+
"""
|
21 |
+
self.df = df
|
22 |
+
|
23 |
+
def _get_column_counts(self, column_name, limit=None):
|
24 |
+
"""
|
25 |
+
Допоміжний метод для отримання частот значень колонки.
|
26 |
+
|
27 |
+
Args:
|
28 |
+
column_name (str): Назва колонки
|
29 |
+
limit (int, optional): Обмеження кількості результатів
|
30 |
+
|
31 |
+
Returns:
|
32 |
+
dict: Словник з частотами або порожній словник
|
33 |
+
"""
|
34 |
+
if column_name not in self.df.columns:
|
35 |
+
return {}
|
36 |
+
|
37 |
+
counts = self.df[column_name].value_counts()
|
38 |
+
if limit:
|
39 |
+
counts = counts.head(limit)
|
40 |
+
|
41 |
+
return counts.to_dict()
|
42 |
+
|
43 |
+
def _check_datetime_column(self, column_name):
|
44 |
+
"""
|
45 |
+
Перевірка наявності та коректності колонки з датами.
|
46 |
+
|
47 |
+
Args:
|
48 |
+
column_name (str): Назва колонки
|
49 |
+
|
50 |
+
Returns:
|
51 |
+
bool: True якщо колонка існує і містить дати, False інакше
|
52 |
+
"""
|
53 |
+
return (column_name in self.df.columns and
|
54 |
+
pd.api.types.is_datetime64_dtype(self.df[column_name]))
|
55 |
+
|
56 |
+
def generate_basic_statistics(self):
|
57 |
+
"""
|
58 |
+
Генерація базової статистики по даним Jira.
|
59 |
+
|
60 |
+
Returns:
|
61 |
+
dict: Словник з базовою статистикою
|
62 |
+
"""
|
63 |
+
try:
|
64 |
+
stats = {
|
65 |
+
'total_tickets': len(self.df),
|
66 |
+
'status_counts': self._get_column_counts('Status'),
|
67 |
+
'type_counts': self._get_column_counts('Issue Type'),
|
68 |
+
'priority_counts': self._get_column_counts('Priority'),
|
69 |
+
'assignee_counts': self._get_column_counts('Assignee', limit=10),
|
70 |
+
'created_stats': {},
|
71 |
+
'updated_stats': {}
|
72 |
+
}
|
73 |
+
|
74 |
+
# Статистика за часом створення
|
75 |
+
if self._check_datetime_column('Created'):
|
76 |
+
created_min = self.df['Created'].min()
|
77 |
+
created_max = self.df['Created'].max()
|
78 |
+
|
79 |
+
# Групування за місяцями
|
80 |
+
if 'Created_Month' in self.df.columns:
|
81 |
+
created_by_month = self.df['Created_Month'].value_counts().sort_index()
|
82 |
+
stats['created_by_month'] = {str(k): v for k, v in created_by_month.items()}
|
83 |
+
|
84 |
+
stats['created_stats'] = {
|
85 |
+
'min': safe_strftime(created_min, "%Y-%m-%d"),
|
86 |
+
'max': safe_strftime(created_max, "%Y-%m-%d"),
|
87 |
+
'last_7_days': len(self.df[self.df['Created'] > (datetime.now() - timedelta(days=7))])
|
88 |
+
}
|
89 |
+
|
90 |
+
# Статистика за часом оновлення
|
91 |
+
if self._check_datetime_column('Updated'):
|
92 |
+
updated_min = self.df['Updated'].min()
|
93 |
+
updated_max = self.df['Updated'].max()
|
94 |
+
|
95 |
+
stats['updated_stats'] = {
|
96 |
+
'min': safe_strftime(updated_min, "%Y-%m-%d"),
|
97 |
+
'max': safe_strftime(updated_max, "%Y-%m-%d"),
|
98 |
+
'last_7_days': len(self.df[self.df['Updated'] > (datetime.now() - timedelta(days=7))])
|
99 |
+
}
|
100 |
+
|
101 |
+
logger.info("Базова статистика успішно згенерована")
|
102 |
+
return stats
|
103 |
+
|
104 |
+
except Exception as e:
|
105 |
+
logger.error(f"Помилка при генерації базової статистики: {e}")
|
106 |
+
return {'error': str(e)}
|
107 |
+
|
108 |
+
def analyze_inactive_issues(self, days=14):
|
109 |
+
"""
|
110 |
+
Аналіз неактивних тікетів (не оновлювались протягом певної кількості днів).
|
111 |
+
|
112 |
+
Args:
|
113 |
+
days (int): Кількість днів неактивності
|
114 |
+
|
115 |
+
Returns:
|
116 |
+
dict: Інформація про неактивні тікети
|
117 |
+
"""
|
118 |
+
try:
|
119 |
+
if not self._check_datetime_column('Updated'):
|
120 |
+
logger.warning("Колонка 'Updated' відсутня або не містить дат")
|
121 |
+
return {'error': "Неможливо аналізувати н��активні тікети"}
|
122 |
+
|
123 |
+
# Визначення неактивних тікетів
|
124 |
+
cutoff_date = datetime.now() - timedelta(days=days)
|
125 |
+
inactive_issues = self.df[self.df['Updated'] < cutoff_date]
|
126 |
+
|
127 |
+
inactive_data = {
|
128 |
+
'total_count': len(inactive_issues),
|
129 |
+
'percentage': round(len(inactive_issues) / len(self.df) * 100, 2) if len(self.df) > 0 else 0,
|
130 |
+
'by_status': {},
|
131 |
+
'by_priority': {},
|
132 |
+
'top_inactive': []
|
133 |
+
}
|
134 |
+
|
135 |
+
# Розподіл за статусами та пріоритетами
|
136 |
+
if len(inactive_issues) > 0:
|
137 |
+
inactive_data['by_status'] = inactive_issues['Status'].value_counts().to_dict() if 'Status' in inactive_issues.columns else {}
|
138 |
+
inactive_data['by_priority'] = inactive_issues['Priority'].value_counts().to_dict() if 'Priority' in inactive_issues.columns else {}
|
139 |
+
|
140 |
+
# Топ 5 неактивних тікетів
|
141 |
+
top_inactive = inactive_issues.sort_values('Updated', ascending=True).head(5)
|
142 |
+
|
143 |
+
for _, row in top_inactive.iterrows():
|
144 |
+
issue_data = {
|
145 |
+
'key': row.get('Issue key', 'Unknown'),
|
146 |
+
'summary': row.get('Summary', 'Unknown'),
|
147 |
+
'status': row.get('Status', 'Unknown'),
|
148 |
+
'last_updated': safe_strftime(row['Updated'], '%Y-%m-%d'),
|
149 |
+
'days_inactive': (datetime.now() - row['Updated']).days if pd.notna(row['Updated']) else 'Unknown'
|
150 |
+
}
|
151 |
+
inactive_data['top_inactive'].append(issue_data)
|
152 |
+
|
153 |
+
logger.info(f"Знайдено {len(inactive_issues)} неактивних тікетів (>{days} днів)")
|
154 |
+
return inactive_data
|
155 |
+
|
156 |
+
except Exception as e:
|
157 |
+
logger.error(f"Помилка при аналізі неактивних тікетів: {e}")
|
158 |
+
return {'error': str(e)}
|
159 |
+
|
160 |
+
def analyze_timeline(self):
|
161 |
+
"""
|
162 |
+
Аналіз часової шкали проекту (зміна стану тікетів з часом).
|
163 |
+
|
164 |
+
Returns:
|
165 |
+
pandas.DataFrame: Дані для візуалізації або None у випадку помилки
|
166 |
+
"""
|
167 |
+
try:
|
168 |
+
if not self._check_datetime_column('Created') or not self._check_datetime_column('Updated'):
|
169 |
+
logger.warning("Відсутні необхідні колонки з датами для аналізу часової шкали")
|
170 |
+
return None
|
171 |
+
|
172 |
+
# Визначення часового діапазону
|
173 |
+
min_date = self.df['Created'].min().date()
|
174 |
+
max_date = self.df['Updated'].max().date()
|
175 |
+
|
176 |
+
# Створення часового ряду для кожного дня
|
177 |
+
date_range = pd.date_range(start=min_date, end=max_date, freq='D')
|
178 |
+
timeline_data = []
|
179 |
+
|
180 |
+
for date in date_range:
|
181 |
+
current_date = date.date()
|
182 |
+
date_str = safe_strftime(date, '%Y-%m-%d')
|
183 |
+
|
184 |
+
# Тікети, створені до цієї дати
|
185 |
+
created_until = self.df[self.df['Created'].dt.date <= current_date]
|
186 |
+
|
187 |
+
# Статуси тікетів на цю дату
|
188 |
+
status_counts = {}
|
189 |
+
|
190 |
+
# Для кожного тікета визначаємо його статус на цю дату
|
191 |
+
for _, row in created_until.iterrows():
|
192 |
+
if pd.notna(row['Updated']) and row['Updated'].date() >= current_date:
|
193 |
+
status = row.get('Status', 'Unknown')
|
194 |
+
status_counts[status] = status_counts.get(status, 0) + 1
|
195 |
+
|
196 |
+
# Додаємо запис для цієї дати
|
197 |
+
timeline_data.append({
|
198 |
+
'Date': date_str,
|
199 |
+
'Total': len(created_until),
|
200 |
+
**status_counts
|
201 |
+
})
|
202 |
+
|
203 |
+
# Створення DataFrame
|
204 |
+
timeline_df = pd.DataFrame(timeline_data)
|
205 |
+
|
206 |
+
logger.info("Часова шкала успішно проаналізована")
|
207 |
+
return timeline_df
|
208 |
+
|
209 |
+
except Exception as e:
|
210 |
+
logger.error(f"Помилка при аналізі часової шкали: {e}")
|
211 |
+
return None
|
212 |
+
|
213 |
+
def analyze_lead_time(self):
|
214 |
+
"""
|
215 |
+
Аналіз часу виконання тікетів (Lead Time).
|
216 |
+
|
217 |
+
Returns:
|
218 |
+
dict: Статистика по часу виконання
|
219 |
+
"""
|
220 |
+
try:
|
221 |
+
if not self._check_datetime_column('Created'):
|
222 |
+
logger.warning("Колонка 'Created' відсутня або не містить дат")
|
223 |
+
return {'error': "Неможливо аналізувати час виконання"}
|
224 |
+
|
225 |
+
if 'Resolved' not in self.df.columns:
|
226 |
+
logger.warning("Колонка 'Resolved' відсутня")
|
227 |
+
return {'error': "Неможливо аналізувати час виконання"}
|
228 |
+
|
229 |
+
# Конвертація колонки Resolved до datetime, якщо потрібно
|
230 |
+
resolved_column = self.df['Resolved']
|
231 |
+
if not pd.api.types.is_datetime64_dtype(resolved_column):
|
232 |
+
resolved_column = pd.to_datetime(resolved_column, errors='coerce')
|
233 |
+
|
234 |
+
# Фільтрація завершених тікетів
|
235 |
+
df_with_resolved = self.df.copy()
|
236 |
+
df_with_resolved['Resolved'] = resolved_column
|
237 |
+
completed_issues = df_with_resolved.dropna(subset=['Resolved'])
|
238 |
+
|
239 |
+
if len(completed_issues) == 0:
|
240 |
+
logger.warning("Немає завершених тікетів для аналізу")
|
241 |
+
return {'total_count': 0}
|
242 |
+
|
243 |
+
# Обчислення Lead Time (в днях)
|
244 |
+
completed_issues['Lead_Time_Days'] = (completed_issues['Resolved'] - completed_issues['Created']).dt.days
|
245 |
+
|
246 |
+
# Фільтрація некоректних значень
|
247 |
+
valid_lead_time = completed_issues[completed_issues['Lead_Time_Days'] >= 0]
|
248 |
+
|
249 |
+
# Якщо немає валідних записів після фільтрації
|
250 |
+
if len(valid_lead_time) == 0:
|
251 |
+
logger.warning("Немає валідних записів для аналізу Lead Time")
|
252 |
+
return {'total_count': 0, 'error': "Немає валідних записів для аналізу Lead Time"}
|
253 |
+
|
254 |
+
lead_time_stats = {
|
255 |
+
'total_count': len(valid_lead_time),
|
256 |
+
'avg_lead_time': round(valid_lead_time['Lead_Time_Days'].mean(), 2),
|
257 |
+
'median_lead_time': round(valid_lead_time['Lead_Time_Days'].median(), 2),
|
258 |
+
'min_lead_time': valid_lead_time['Lead_Time_Days'].min(),
|
259 |
+
'max_lead_time': valid_lead_time['Lead_Time_Days'].max(),
|
260 |
+
'by_type': {},
|
261 |
+
'by_priority': {}
|
262 |
+
}
|
263 |
+
|
264 |
+
# Розподіл за типами і пріоритетами
|
265 |
+
if 'Issue Type' in valid_lead_time.columns:
|
266 |
+
lead_time_by_type = valid_lead_time.groupby('Issue Type')['Lead_Time_Days'].mean().round(2)
|
267 |
+
lead_time_stats['by_type'] = lead_time_by_type.to_dict()
|
268 |
+
|
269 |
+
if 'Priority' in valid_lead_time.columns:
|
270 |
+
lead_time_by_priority = valid_lead_time.groupby('Priority')['Lead_Time_Days'].mean().round(2)
|
271 |
+
lead_time_stats['by_priority'] = lead_time_by_priority.to_dict()
|
272 |
+
|
273 |
+
logger.info("Час виконання успішно проаналізований")
|
274 |
+
return lead_time_stats
|
275 |
+
|
276 |
+
except Exception as e:
|
277 |
+
logger.error(f"Помилка при аналізі часу виконання: {e}")
|
278 |
+
return {'error': str(e)}
|
modules/data_analysis/visualizations.py
ADDED
@@ -0,0 +1,640 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import pandas as pd
|
2 |
+
import numpy as np
|
3 |
+
import matplotlib.pyplot as plt
|
4 |
+
import seaborn as sns
|
5 |
+
from datetime import datetime, timedelta
|
6 |
+
import logging
|
7 |
+
|
8 |
+
logger = logging.getLogger(__name__)
|
9 |
+
|
10 |
+
|
11 |
+
class JiraVisualizer:
|
12 |
+
"""
|
13 |
+
Клас для створення візуалізацій даних Jira
|
14 |
+
"""
|
15 |
+
def __init__(self, df):
|
16 |
+
"""
|
17 |
+
Ініціалізація візуалізатора.
|
18 |
+
|
19 |
+
Args:
|
20 |
+
df (pandas.DataFrame): DataFrame з даними Jira
|
21 |
+
"""
|
22 |
+
self.df = df
|
23 |
+
self._setup_plot_style()
|
24 |
+
|
25 |
+
def _setup_plot_style(self):
|
26 |
+
"""
|
27 |
+
Налаштування стилю візуалізацій.
|
28 |
+
"""
|
29 |
+
plt.style.use('ggplot')
|
30 |
+
sns.set(style="whitegrid")
|
31 |
+
# Налаштування для українських символів
|
32 |
+
plt.rcParams['font.family'] = 'DejaVu Sans'
|
33 |
+
|
34 |
+
def plot_status_counts(self):
|
35 |
+
"""
|
36 |
+
Створення діаграми розподілу тікетів за статусами.
|
37 |
+
|
38 |
+
Returns:
|
39 |
+
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки
|
40 |
+
"""
|
41 |
+
try:
|
42 |
+
if 'Status' not in self.df.columns:
|
43 |
+
logger.warning("Колонка 'Status' відсутня")
|
44 |
+
return None
|
45 |
+
|
46 |
+
status_counts = self.df['Status'].value_counts()
|
47 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
48 |
+
|
49 |
+
# Спроба впорядкувати статуси логічно
|
50 |
+
try:
|
51 |
+
status_order = ['To Do', 'In Progress', 'In Review', 'Done', 'Closed']
|
52 |
+
available_statuses = [s for s in status_order if s in status_counts.index]
|
53 |
+
other_statuses = [s for s in status_counts.index if s not in status_order]
|
54 |
+
ordered_statuses = available_statuses + other_statuses
|
55 |
+
status_counts = status_counts.reindex(ordered_statuses)
|
56 |
+
except Exception as ex:
|
57 |
+
logger.warning(f"Не вдалося впорядкувати статуси: {ex}")
|
58 |
+
|
59 |
+
sns.barplot(x=status_counts.index, y=status_counts.values, ax=ax)
|
60 |
+
for i, v in enumerate(status_counts.values):
|
61 |
+
ax.text(i, v + 0.5, str(v), ha='center')
|
62 |
+
|
63 |
+
ax.set_title('Розподіл тікетів за статусами')
|
64 |
+
ax.set_xlabel('Статус')
|
65 |
+
ax.set_ylabel('Кількість')
|
66 |
+
plt.xticks(rotation=45)
|
67 |
+
plt.tight_layout()
|
68 |
+
|
69 |
+
logger.info("Діаграма статусів успішно створена")
|
70 |
+
return fig
|
71 |
+
|
72 |
+
except Exception as e:
|
73 |
+
logger.error(f"Помилка при створенні діаграми статусів: {e}")
|
74 |
+
return None
|
75 |
+
|
76 |
+
def plot_priority_counts(self):
|
77 |
+
"""
|
78 |
+
Створення діаграми розподілу тікетів за пріоритетами.
|
79 |
+
|
80 |
+
Returns:
|
81 |
+
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки
|
82 |
+
"""
|
83 |
+
try:
|
84 |
+
if 'Priority' not in self.df.columns:
|
85 |
+
logger.warning("Колонка 'Priority' відсутня")
|
86 |
+
return None
|
87 |
+
|
88 |
+
priority_counts = self.df['Priority'].value_counts()
|
89 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
90 |
+
|
91 |
+
# Спроба впорядкувати пріоритети логічно
|
92 |
+
try:
|
93 |
+
priority_order = ['Highest', 'High', 'Medium', 'Low', 'Lowest']
|
94 |
+
available_priorities = [p for p in priority_order if p in priority_counts.index]
|
95 |
+
other_priorities = [p for p in priority_counts.index if p not in priority_order]
|
96 |
+
ordered_priorities = available_priorities + other_priorities
|
97 |
+
priority_counts = priority_counts.reindex(ordered_priorities)
|
98 |
+
except Exception as ex:
|
99 |
+
logger.warning(f"Не вдалося впорядкувати пріоритети: {ex}")
|
100 |
+
|
101 |
+
colors = ['#FF5555', '#FF9C5A', '#FFCC5A', '#5AFF96', '#5AC8FF']
|
102 |
+
if len(priority_counts) <= len(colors):
|
103 |
+
sns.barplot(x=priority_counts.index, y=priority_counts.values, ax=ax, palette=colors[:len(priority_counts)])
|
104 |
+
else:
|
105 |
+
sns.barplot(x=priority_counts.index, y=priority_counts.values, ax=ax)
|
106 |
+
|
107 |
+
for i, v in enumerate(priority_counts.values):
|
108 |
+
ax.text(i, v + 0.5, str(v), ha='center')
|
109 |
+
|
110 |
+
ax.set_title('Розподіл тікетів за пріоритетами')
|
111 |
+
ax.set_xlabel('Пріоритет')
|
112 |
+
ax.set_ylabel('Кількість')
|
113 |
+
plt.xticks(rotation=45)
|
114 |
+
plt.tight_layout()
|
115 |
+
|
116 |
+
logger.info("Діаграма пріоритетів успішно створена")
|
117 |
+
return fig
|
118 |
+
|
119 |
+
except Exception as e:
|
120 |
+
logger.error(f"Помилка при створенні діаграми пріоритетів: {e}")
|
121 |
+
return None
|
122 |
+
|
123 |
+
def plot_type_counts(self):
|
124 |
+
"""
|
125 |
+
Створення діаграми розподілу тікетів за типами.
|
126 |
+
|
127 |
+
Returns:
|
128 |
+
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки
|
129 |
+
"""
|
130 |
+
try:
|
131 |
+
if 'Issue Type' not in self.df.columns:
|
132 |
+
logger.warning("Колонка 'Issue Type' відсутня")
|
133 |
+
return None
|
134 |
+
|
135 |
+
type_counts = self.df['Issue Type'].value_counts()
|
136 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
137 |
+
sns.barplot(x=type_counts.index, y=type_counts.values, ax=ax)
|
138 |
+
|
139 |
+
for i, v in enumerate(type_counts.values):
|
140 |
+
ax.text(i, v + 0.5, str(v), ha='center')
|
141 |
+
|
142 |
+
ax.set_title('Розподіл тікетів за типами')
|
143 |
+
ax.set_xlabel('Тип')
|
144 |
+
ax.set_ylabel('Кількість')
|
145 |
+
plt.xticks(rotation=45)
|
146 |
+
plt.tight_layout()
|
147 |
+
|
148 |
+
logger.info("Діаграма типів успішно створена")
|
149 |
+
return fig
|
150 |
+
|
151 |
+
except Exception as e:
|
152 |
+
logger.error(f"Помилка при створенні діаграми типів: {e}")
|
153 |
+
return None
|
154 |
+
|
155 |
+
def plot_created_timeline(self):
|
156 |
+
"""
|
157 |
+
Створення часової діаграми створення тікетів.
|
158 |
+
|
159 |
+
Returns:
|
160 |
+
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки
|
161 |
+
"""
|
162 |
+
try:
|
163 |
+
if 'Created' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Created']):
|
164 |
+
logger.warning("Колонка 'Created' відсутня або не містить дат")
|
165 |
+
return None
|
166 |
+
|
167 |
+
if 'Created_Date' not in self.df.columns:
|
168 |
+
self.df['Created_Date'] = self.df['Created'].dt.date
|
169 |
+
|
170 |
+
created_by_date = self.df['Created_Date'].value_counts().sort_index()
|
171 |
+
fig, ax = plt.subplots(figsize=(12, 6))
|
172 |
+
created_by_date.plot(kind='line', marker='o', ax=ax)
|
173 |
+
|
174 |
+
ax.set_title('Кількість створених тікетів за датами')
|
175 |
+
ax.set_xlabel('Дата')
|
176 |
+
ax.set_ylabel('Кількість')
|
177 |
+
ax.grid(True)
|
178 |
+
plt.tight_layout()
|
179 |
+
|
180 |
+
logger.info("Часова діаграма успішно створена")
|
181 |
+
return fig
|
182 |
+
|
183 |
+
except Exception as e:
|
184 |
+
logger.error(f"Помилка при створенні часової діаграми: {e}")
|
185 |
+
return None
|
186 |
+
|
187 |
+
def plot_inactive_issues(self, days=14):
|
188 |
+
"""
|
189 |
+
Створення діаграми неактивних тікетів.
|
190 |
+
|
191 |
+
Args:
|
192 |
+
days (int): Кількість днів неактивності
|
193 |
+
|
194 |
+
Returns:
|
195 |
+
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки
|
196 |
+
"""
|
197 |
+
try:
|
198 |
+
if 'Updated' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Updated']):
|
199 |
+
logger.warning("Колонка 'Updated' відсутня або не містить дат")
|
200 |
+
return None
|
201 |
+
|
202 |
+
cutoff_date = datetime.now() - timedelta(days=days)
|
203 |
+
inactive_issues = self.df[self.df['Updated'] < cutoff_date]
|
204 |
+
|
205 |
+
if len(inactive_issues) == 0:
|
206 |
+
logger.warning("Немає неактивних тікетів для візуалізації")
|
207 |
+
return None
|
208 |
+
|
209 |
+
if 'Status' in inactive_issues.columns:
|
210 |
+
inactive_by_status = inactive_issues['Status'].value_counts()
|
211 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
212 |
+
sns.barplot(x=inactive_by_status.index, y=inactive_by_status.values, ax=ax)
|
213 |
+
for i, v in enumerate(inactive_by_status.values):
|
214 |
+
ax.text(i, v + 0.5, str(v), ha='center')
|
215 |
+
|
216 |
+
ax.set_title(f'Розподіл неактивних тікетів за статусами (>{days} днів)')
|
217 |
+
ax.set_xlabel('Статус')
|
218 |
+
ax.set_ylabel('Кількість')
|
219 |
+
plt.xticks(rotation=45)
|
220 |
+
plt.tight_layout()
|
221 |
+
|
222 |
+
logger.info("Діаграма неактивних тікетів успішно створена")
|
223 |
+
return fig
|
224 |
+
else:
|
225 |
+
logger.warning("Колонка 'Status' відсутня для неактивних тікетів")
|
226 |
+
return None
|
227 |
+
|
228 |
+
except Exception as e:
|
229 |
+
logger.error(f"Помилка при створенні діаграми неактивних тікетів: {e}")
|
230 |
+
return None
|
231 |
+
|
232 |
+
def plot_status_timeline(self, timeline_df=None):
|
233 |
+
"""
|
234 |
+
Створення діаграми зміни статусів з часом.
|
235 |
+
|
236 |
+
Args:
|
237 |
+
timeline_df (pandas.DataFrame): DataFrame з часовими даними або None для автоматичного генерування
|
238 |
+
|
239 |
+
Returns:
|
240 |
+
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки
|
241 |
+
"""
|
242 |
+
try:
|
243 |
+
if timeline_df is None:
|
244 |
+
if 'Created' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Created']):
|
245 |
+
logger.warning("Колонка 'Created' відсутня або не містить дат")
|
246 |
+
return None
|
247 |
+
|
248 |
+
if 'Updated' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Updated']):
|
249 |
+
logger.warning("Колонка 'Updated' відсутня або не містить дат")
|
250 |
+
return None
|
251 |
+
|
252 |
+
min_date = self.df['Created'].min().date()
|
253 |
+
max_date = self.df['Updated'].max().date()
|
254 |
+
date_range = pd.date_range(start=min_date, end=max_date, freq='D')
|
255 |
+
timeline_data = []
|
256 |
+
|
257 |
+
for date in date_range:
|
258 |
+
date_str = date.strftime('%Y-%m-%d')
|
259 |
+
created_until = self.df[self.df['Created'].dt.date <= date.date()]
|
260 |
+
status_counts = {}
|
261 |
+
for _, row in created_until.iterrows():
|
262 |
+
if row['Updated'].date() >= date.date():
|
263 |
+
status = row.get('Status', 'Unknown')
|
264 |
+
status_counts[status] = status_counts.get(status, 0) + 1
|
265 |
+
timeline_data.append({
|
266 |
+
'Date': date_str,
|
267 |
+
'Total': len(created_until),
|
268 |
+
**status_counts
|
269 |
+
})
|
270 |
+
|
271 |
+
timeline_df = pd.DataFrame(timeline_data)
|
272 |
+
timeline_df['Date'] = pd.to_datetime(timeline_df['Date'])
|
273 |
+
else:
|
274 |
+
if not pd.api.types.is_datetime64_dtype(timeline_df['Date']):
|
275 |
+
timeline_df['Date'] = pd.to_datetime(timeline_df['Date'])
|
276 |
+
|
277 |
+
status_columns = [col for col in timeline_df.columns if col not in ['Date', 'Total']]
|
278 |
+
if not status_columns:
|
279 |
+
logger.warning("Немає даних про статуси для візуалізації")
|
280 |
+
return None
|
281 |
+
|
282 |
+
fig, ax = plt.subplots(figsize=(14, 8))
|
283 |
+
status_data = timeline_df[['Date'] + status_columns].set_index('Date')
|
284 |
+
status_data.plot.area(ax=ax, stacked=True, alpha=0.7)
|
285 |
+
|
286 |
+
ax.set_title('Зміна статусів тікетів з часом')
|
287 |
+
ax.set_xlabel('Дата')
|
288 |
+
ax.set_ylabel('Кількість тікетів')
|
289 |
+
ax.grid(True)
|
290 |
+
plt.tight_layout()
|
291 |
+
|
292 |
+
logger.info("Часова діаграма статусів успішно створена")
|
293 |
+
return fig
|
294 |
+
|
295 |
+
except Exception as e:
|
296 |
+
logger.error(f"Помилка при створенні часової діаграми статусів: {e}")
|
297 |
+
return None
|
298 |
+
|
299 |
+
def plot_lead_time_by_type(self):
|
300 |
+
"""
|
301 |
+
Створення діаграми часу виконання за типами тікетів.
|
302 |
+
|
303 |
+
Returns:
|
304 |
+
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки
|
305 |
+
"""
|
306 |
+
try:
|
307 |
+
if 'Created' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Created']):
|
308 |
+
logger.warning("Колонка 'Created' відсутня або не містить дат")
|
309 |
+
return None
|
310 |
+
|
311 |
+
if 'Resolved' not in self.df.columns:
|
312 |
+
logger.warning("Колонка 'Resolved' відсутня")
|
313 |
+
return None
|
314 |
+
|
315 |
+
if 'Issue Type' not in self.df.columns:
|
316 |
+
logger.warning("Колонка 'Issue Type' відсутня")
|
317 |
+
return None
|
318 |
+
|
319 |
+
if not pd.api.types.is_datetime64_dtype(self.df['Resolved']):
|
320 |
+
self.df['Resolved'] = pd.to_datetime(self.df['Resolved'], errors='coerce')
|
321 |
+
|
322 |
+
completed_issues = self.df.dropna(subset=['Resolved'])
|
323 |
+
if len(completed_issues) == 0:
|
324 |
+
logger.warning("Немає завершених тікетів для ан��лізу")
|
325 |
+
return None
|
326 |
+
|
327 |
+
completed_issues['Lead_Time_Days'] = (completed_issues['Resolved'] - completed_issues['Created']).dt.days
|
328 |
+
valid_lead_time = completed_issues[completed_issues['Lead_Time_Days'] >= 0]
|
329 |
+
if len(valid_lead_time) == 0:
|
330 |
+
logger.warning("Немає валідних даних про час виконання")
|
331 |
+
return None
|
332 |
+
|
333 |
+
lead_time_by_type = valid_lead_time.groupby('Issue Type')['Lead_Time_Days'].mean()
|
334 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
335 |
+
sns.barplot(x=lead_time_by_type.index, y=lead_time_by_type.values, ax=ax)
|
336 |
+
for i, v in enumerate(lead_time_by_type.values):
|
337 |
+
ax.text(i, v + 0.5, f"{v:.1f}", ha='center')
|
338 |
+
|
339 |
+
ax.set_title('Середній час виконання тікетів за типами (дні)')
|
340 |
+
ax.set_xlabel('Тип')
|
341 |
+
ax.set_ylabel('Дні')
|
342 |
+
plt.xticks(rotation=45)
|
343 |
+
plt.tight_layout()
|
344 |
+
|
345 |
+
logger.info("Діаграма часу виконання успішно створена")
|
346 |
+
return fig
|
347 |
+
|
348 |
+
except Exception as e:
|
349 |
+
logger.error(f"Помилка при створенні діаграми часу виконання: {e}")
|
350 |
+
return None
|
351 |
+
|
352 |
+
# Нові методи, додані до класу JiraVisualizer
|
353 |
+
|
354 |
+
def plot_assignee_counts(self, limit=10):
|
355 |
+
"""
|
356 |
+
Створення діаграми розподілу тікетів за призначеними користувачами.
|
357 |
+
|
358 |
+
Args:
|
359 |
+
limit (int): Обмеження на кількість користувачів для відображення
|
360 |
+
|
361 |
+
Returns:
|
362 |
+
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки
|
363 |
+
"""
|
364 |
+
try:
|
365 |
+
if 'Assignee' not in self.df.columns:
|
366 |
+
logger.warning("Колонка 'Assignee' відсутня")
|
367 |
+
return None
|
368 |
+
|
369 |
+
assignee_counts = self.df['Assignee'].value_counts().head(limit)
|
370 |
+
fig, ax = plt.subplots(figsize=(14, 6))
|
371 |
+
sns.barplot(x=assignee_counts.index, y=assignee_counts.values, ax=ax)
|
372 |
+
for i, v in enumerate(assignee_counts.values):
|
373 |
+
ax.text(i, v + 0.5, str(v), ha='center')
|
374 |
+
|
375 |
+
ax.set_title(f'Кількість тікетів за призначеними користувачами (Топ {limit})')
|
376 |
+
ax.set_xlabel('Призначений користувач')
|
377 |
+
ax.set_ylabel('Кількість')
|
378 |
+
plt.xticks(rotation=45)
|
379 |
+
plt.tight_layout()
|
380 |
+
|
381 |
+
logger.info("Діаграма призначених користувачів успішно створена")
|
382 |
+
return fig
|
383 |
+
except Exception as e:
|
384 |
+
logger.error(f"Помилка при створенні діаграми призначених користувачів: {e}")
|
385 |
+
return None
|
386 |
+
|
387 |
+
def plot_timeline(self, date_column='Created', groupby='day', cumulative=False):
|
388 |
+
"""
|
389 |
+
Створення часової діаграми тікетів.
|
390 |
+
|
391 |
+
Args:
|
392 |
+
date_column (str): Колонка з датою ('Created' або 'Updated')
|
393 |
+
groupby (str): Рівень групування ('day', 'week', 'month')
|
394 |
+
cumulative (bool): Чи показувати кумулятивну суму
|
395 |
+
|
396 |
+
Returns:
|
397 |
+
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки
|
398 |
+
"""
|
399 |
+
try:
|
400 |
+
if date_column not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df[date_column]):
|
401 |
+
logger.warning(f"Колонка '{date_column}' відсутня або не містить дати")
|
402 |
+
return None
|
403 |
+
|
404 |
+
date_col = f"{date_column}_Date" if f"{date_column}_Date" in self.df.columns else date_column
|
405 |
+
if f"{date_column}_Date" not in self.df.columns:
|
406 |
+
self.df[f"{date_column}_Date"] = self.df[date_column].dt.date
|
407 |
+
date_col = f"{date_column}_Date"
|
408 |
+
|
409 |
+
if groupby == 'week':
|
410 |
+
grouped = self.df[date_column].dt.to_period('W').value_counts().sort_index()
|
411 |
+
title_period = 'тижнями'
|
412 |
+
elif groupby == 'month':
|
413 |
+
grouped = self.df[date_column].dt.to_period('M').value_counts().sort_index()
|
414 |
+
title_period = 'місяцями'
|
415 |
+
else:
|
416 |
+
grouped = self.df[date_col].value_counts().sort_index()
|
417 |
+
title_period = 'датами'
|
418 |
+
|
419 |
+
if cumulative:
|
420 |
+
grouped = grouped.cumsum()
|
421 |
+
title_prefix = 'Загальна кількість'
|
422 |
+
else:
|
423 |
+
title_prefix = 'Кількість'
|
424 |
+
|
425 |
+
fig, ax = plt.subplots(figsize=(14, 6))
|
426 |
+
grouped.plot(kind='line', marker='o', ax=ax)
|
427 |
+
|
428 |
+
ax.set_title(f'{title_prefix} {date_column.lower()}них тікетів за {title_period}')
|
429 |
+
ax.set_xlabel('Період')
|
430 |
+
ax.set_ylabel('Кількість')
|
431 |
+
ax.grid(True)
|
432 |
+
plt.tight_layout()
|
433 |
+
|
434 |
+
logger.info(f"Часова діаграма для {date_column} успішно створена")
|
435 |
+
return fig
|
436 |
+
except Exception as e:
|
437 |
+
logger.error(f"Помилка при створенні часової діаграми: {e}")
|
438 |
+
return None
|
439 |
+
|
440 |
+
def plot_heatmap(self, row_col='Issue Type', column_col='Status'):
|
441 |
+
"""
|
442 |
+
Створення теплової карти для візуалізації взаємозв'язку між двома категоріями.
|
443 |
+
|
444 |
+
Args:
|
445 |
+
row_col (str): Назва колонки для рядків (наприклад, 'Issue Type')
|
446 |
+
column_col (str): Назва колонки для стовпців (наприклад, 'Status')
|
447 |
+
|
448 |
+
Returns:
|
449 |
+
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки
|
450 |
+
"""
|
451 |
+
try:
|
452 |
+
if row_col not in self.df.columns or column_col not in self.df.columns:
|
453 |
+
logger.warning(f"Колонки '{row_col}' або '{column_col}' відсутні в даних")
|
454 |
+
return None
|
455 |
+
|
456 |
+
pivot_table = pd.crosstab(self.df[row_col], self.df[column_col])
|
457 |
+
fig, ax = plt.subplots(figsize=(14, 8))
|
458 |
+
sns.heatmap(pivot_table, annot=True, fmt='d', cmap='YlGnBu', ax=ax)
|
459 |
+
|
460 |
+
ax.set_title(f'Розподіл тікетів: {row_col} за {column_col}')
|
461 |
+
plt.tight_layout()
|
462 |
+
|
463 |
+
logger.info(f"Теплова карта для {row_col} за {column_col} успішно створена")
|
464 |
+
return fig
|
465 |
+
except Exception as e:
|
466 |
+
logger.error(f"Помилка при створенні теплової карти: {e}")
|
467 |
+
return None
|
468 |
+
|
469 |
+
def plot_project_timeline(self):
|
470 |
+
"""
|
471 |
+
Створення часової шкали проекту, що показує зміну статусів з часом.
|
472 |
+
|
473 |
+
Returns:
|
474 |
+
tuple: (fig1, fig2) - об'єкти figure для різних візуалізацій або (None, None) у випадку помилки
|
475 |
+
"""
|
476 |
+
try:
|
477 |
+
if 'Created' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Created']):
|
478 |
+
logger.warning("Колонка 'Created' відсутня або не містить дати")
|
479 |
+
return None, None
|
480 |
+
|
481 |
+
if 'Updated' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Updated']):
|
482 |
+
logger.warning("Колонка 'Updated' відсутня або не містить дати")
|
483 |
+
return None, None
|
484 |
+
|
485 |
+
if 'Status' not in self.df.columns:
|
486 |
+
logger.warning("Колонка 'Status' відсутня")
|
487 |
+
return None, None
|
488 |
+
|
489 |
+
min_date = self.df['Created'].min().date()
|
490 |
+
max_date = self.df['Updated'].max().date()
|
491 |
+
date_range = pd.date_range(start=min_date, end=max_date, freq='D')
|
492 |
+
timeline_data = []
|
493 |
+
|
494 |
+
for date in date_range:
|
495 |
+
date_str = date.strftime('%Y-%m-%d')
|
496 |
+
created_until = self.df[self.df['Created'].dt.date <= date.date()]
|
497 |
+
status_counts = {}
|
498 |
+
for _, row in created_until.iterrows():
|
499 |
+
if row['Updated'].date() >= date.date():
|
500 |
+
status = row.get('Status', 'Unknown')
|
501 |
+
status_counts[status] = status_counts.get(status, 0) + 1
|
502 |
+
timeline_data.append({
|
503 |
+
'Date': date_str,
|
504 |
+
'Total': len(created_until),
|
505 |
+
**status_counts
|
506 |
+
})
|
507 |
+
|
508 |
+
timeline_df = pd.DataFrame(timeline_data)
|
509 |
+
timeline_df['Date'] = pd.to_datetime(timeline_df['Date'])
|
510 |
+
|
511 |
+
fig1, ax1 = plt.subplots(figsize=(16, 8))
|
512 |
+
ax1.plot(timeline_df['Date'], timeline_df['Total'], marker='o', linewidth=2, label='Загальна кількість')
|
513 |
+
status_columns = [col for col in timeline_df.columns if col not in ['Date', 'Total']]
|
514 |
+
for status in status_columns:
|
515 |
+
ax1.plot(timeline_df['Date'], timeline_df[status], marker='.', linestyle='--', label=status)
|
516 |
+
|
517 |
+
ax1.set_title('Зміна стану проекту з часом')
|
518 |
+
ax1.set_xlabel('Дата')
|
519 |
+
ax1.set_ylabel('Кількість тікетів')
|
520 |
+
plt.xticks(rotation=45)
|
521 |
+
ax1.grid(True)
|
522 |
+
ax1.legend()
|
523 |
+
plt.tight_layout()
|
524 |
+
|
525 |
+
fig2, ax2 = plt.subplots(figsize=(16, 8))
|
526 |
+
status_data = timeline_df[['Date'] + status_columns].set_index('Date')
|
527 |
+
status_data.plot.area(ax=ax2, stacked=True, alpha=0.7)
|
528 |
+
|
529 |
+
ax2.set_title('Склад тікетів за статусами')
|
530 |
+
ax2.set_xlabel('Дата')
|
531 |
+
ax2.set_ylabel('Кількість тікетів')
|
532 |
+
ax2.grid(True)
|
533 |
+
plt.tight_layout()
|
534 |
+
|
535 |
+
logger.info("Часова шкала проекту успішно створена")
|
536 |
+
return fig1, fig2
|
537 |
+
except Exception as e:
|
538 |
+
logger.error(f"Помилка при створенні часової шкали проекту: {e}")
|
539 |
+
return None, None
|
540 |
+
|
541 |
+
def generate_infographic(self):
|
542 |
+
"""
|
543 |
+
Генерація комплексної інфографіки з ключовими показниками
|
544 |
+
|
545 |
+
Returns:
|
546 |
+
matplotlib.figure.Figure: Об'єкт figure з інфографікою
|
547 |
+
"""
|
548 |
+
try:
|
549 |
+
fig = plt.figure(figsize=(20, 15))
|
550 |
+
fig.suptitle('Зведений аналіз проекту в Jira', fontsize=24)
|
551 |
+
|
552 |
+
ax1 = fig.add_subplot(2, 2, 1)
|
553 |
+
if 'Status' in self.df.columns:
|
554 |
+
status_counts = self.df['Status'].value_counts()
|
555 |
+
sns.barplot(x=status_counts.index, y=status_counts.values, ax=ax1)
|
556 |
+
ax1.set_title('Розподіл за статусами')
|
557 |
+
ax1.set_xlabel('Статус')
|
558 |
+
ax1.set_ylabel('Кількість')
|
559 |
+
ax1.tick_params(axis='x', rotation=45)
|
560 |
+
|
561 |
+
ax2 = fig.add_subplot(2, 2, 2)
|
562 |
+
if 'Priority' in self.df.columns:
|
563 |
+
priority_counts = self.df['Priority'].value_counts()
|
564 |
+
try:
|
565 |
+
priority_order = ['Highest', 'High', 'Medium', 'Low', 'Lowest']
|
566 |
+
priority_counts = priority_counts.reindex(priority_order, fill_value=0)
|
567 |
+
except Exception as ex:
|
568 |
+
logger.warning(f"Не вдалося впорядкувати пріоритети: {ex}")
|
569 |
+
colors = ['#FF5555', '#FF9C5A', '#FFCC5A', '#5AFF96', '#5AC8FF']
|
570 |
+
sns.barplot(x=priority_counts.index, y=priority_counts.values, ax=ax2, palette=colors[:len(priority_counts)])
|
571 |
+
ax2.set_title('Розподіл за пріоритетами')
|
572 |
+
ax2.set_xlabel('Пріоритет')
|
573 |
+
ax2.set_ylabel('Кількість')
|
574 |
+
ax2.tick_params(axis='x', rotation=45)
|
575 |
+
|
576 |
+
ax3 = fig.add_subplot(2, 2, 3)
|
577 |
+
if 'Created' in self.df.columns and pd.api.types.is_datetime64_dtype(self.df['Created']):
|
578 |
+
created_dates = self.df['Created'].dt.date.value_counts().sort_index()
|
579 |
+
created_cumulative = created_dates.cumsum()
|
580 |
+
created_cumulative.plot(ax=ax3, marker='o')
|
581 |
+
ax3.set_title('Кумулятивне створення тікетів')
|
582 |
+
ax3.set_xlabel('Дата')
|
583 |
+
ax3.set_ylabel('Кількість')
|
584 |
+
ax3.grid(True)
|
585 |
+
|
586 |
+
ax4 = fig.add_subplot(2, 2, 4)
|
587 |
+
if 'Status' in self.df.columns and 'Issue Type' in self.df.columns:
|
588 |
+
pivot_table = pd.crosstab(self.df['Issue Type'], self.df['Status'])
|
589 |
+
sns.heatmap(pivot_table, annot=True, fmt='d', cmap='YlGnBu', ax=ax4)
|
590 |
+
ax4.set_title('Розподіл: Типи за Статусами')
|
591 |
+
ax4.tick_params(axis='x', rotation=45)
|
592 |
+
|
593 |
+
plt.tight_layout(rect=[0, 0, 1, 0.96])
|
594 |
+
|
595 |
+
logger.info("Інфографіка успішно створена")
|
596 |
+
return fig
|
597 |
+
except Exception as e:
|
598 |
+
logger.error(f"Помилка при створенні інфографіки: {e}")
|
599 |
+
return None
|
600 |
+
|
601 |
+
def plot_all(self, output_dir=None):
|
602 |
+
"""
|
603 |
+
Створення та збереження всіх діаграм.
|
604 |
+
|
605 |
+
Args:
|
606 |
+
output_dir (str): Директорія для збереження діаграм.
|
607 |
+
Якщо None, діаграми не зберігаються.
|
608 |
+
|
609 |
+
Returns:
|
610 |
+
dict: Словник з об'єктами figure для всіх діаграм
|
611 |
+
"""
|
612 |
+
plots = {}
|
613 |
+
|
614 |
+
plots['status'] = self.plot_status_counts()
|
615 |
+
plots['priority'] = self.plot_priority_counts()
|
616 |
+
plots['type'] = self.plot_type_counts()
|
617 |
+
|
618 |
+
plots['assignee'] = self.plot_assignee_counts(limit=10)
|
619 |
+
plots['created_timeline'] = self.plot_timeline(date_column='Created', groupby='day')
|
620 |
+
plots['updated_timeline'] = self.plot_timeline(date_column='Updated', groupby='day')
|
621 |
+
plots['created_cumulative'] = self.plot_timeline(date_column='Created', cumulative=True)
|
622 |
+
plots['inactive'] = self.plot_inactive_issues()
|
623 |
+
plots['heatmap_type_status'] = self.plot_heatmap(row_col='Issue Type', column_col='Status')
|
624 |
+
|
625 |
+
timeline_plots = self.plot_project_timeline()
|
626 |
+
if timeline_plots[0] is not None:
|
627 |
+
plots['project_timeline'] = timeline_plots[0]
|
628 |
+
plots['project_composition'] = timeline_plots[1]
|
629 |
+
|
630 |
+
if output_dir:
|
631 |
+
from pathlib import Path
|
632 |
+
output_path = Path(output_dir)
|
633 |
+
output_path.mkdir(exist_ok=True, parents=True)
|
634 |
+
for name, fig in plots.items():
|
635 |
+
if fig:
|
636 |
+
fig_path = output_path / f"{name}.png"
|
637 |
+
fig.savefig(fig_path, dpi=300)
|
638 |
+
logger.info(f"Діаграма {name} збережена у {fig_path}")
|
639 |
+
|
640 |
+
return plots
|
modules/data_import/csv_importer.py
ADDED
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import pandas as pd
|
2 |
+
from datetime import datetime
|
3 |
+
import logging
|
4 |
+
import os
|
5 |
+
from pathlib import Path
|
6 |
+
import io
|
7 |
+
import hashlib
|
8 |
+
|
9 |
+
logger = logging.getLogger(__name__)
|
10 |
+
|
11 |
+
class JiraCsvImporter:
|
12 |
+
"""
|
13 |
+
Клас для імпорту даних з CSV-файлів Jira
|
14 |
+
"""
|
15 |
+
def __init__(self, file_path):
|
16 |
+
"""
|
17 |
+
Ініціалізація імпортера CSV.
|
18 |
+
|
19 |
+
Args:
|
20 |
+
file_path (str): Шлях до CSV-файлу
|
21 |
+
"""
|
22 |
+
self.file_path = file_path
|
23 |
+
self.df = None
|
24 |
+
self.file_hash = None
|
25 |
+
|
26 |
+
def load_data(self):
|
27 |
+
"""
|
28 |
+
Завантаження даних з CSV-файлу.
|
29 |
+
|
30 |
+
Returns:
|
31 |
+
pandas.DataFrame: Завантажені дані або None у випадку помилки
|
32 |
+
"""
|
33 |
+
try:
|
34 |
+
logger.info(f"Завантаження CSV-файлу: {self.file_path}")
|
35 |
+
print(f"Завантаження CSV-файлу: {self.file_path}") # Додаткове логування в консоль
|
36 |
+
|
37 |
+
# Перевірка існування файлу
|
38 |
+
if not os.path.exists(self.file_path):
|
39 |
+
logger.error(f"Файл не знайдено: {self.file_path}")
|
40 |
+
print(f"Файл не знайдено: {self.file_path}")
|
41 |
+
return None
|
42 |
+
|
43 |
+
# Перевірка розміру файлу
|
44 |
+
file_size = os.path.getsize(self.file_path)
|
45 |
+
logger.info(f"Розмір файлу: {file_size} байт")
|
46 |
+
|
47 |
+
if file_size == 0:
|
48 |
+
logger.error("Файл порожній")
|
49 |
+
return None
|
50 |
+
|
51 |
+
# Генеруємо хеш файлу для відстеження змін
|
52 |
+
self.file_hash = self._generate_file_hash()
|
53 |
+
if self.file_hash:
|
54 |
+
logger.info(f"Згенеровано хеш CSV файлу: {self.file_hash}")
|
55 |
+
|
56 |
+
# Додаткове логування дозволів на файл
|
57 |
+
try:
|
58 |
+
import stat
|
59 |
+
st = os.stat(self.file_path)
|
60 |
+
permissions = stat.filemode(st.st_mode)
|
61 |
+
logger.info(f"Дозволи файлу: {permissions}")
|
62 |
+
except Exception as e:
|
63 |
+
logger.warning(f"Не вдалося отримати дозволи файлу: {e}")
|
64 |
+
|
65 |
+
# Спробуємо різні методи зчитування файлу
|
66 |
+
success = False
|
67 |
+
|
68 |
+
# Метод 1: Стандартний pandas.read_csv
|
69 |
+
try:
|
70 |
+
self.df = pd.read_csv(self.file_path)
|
71 |
+
logger.info("Метод 1 (стандартний read_csv) успішний")
|
72 |
+
success = True
|
73 |
+
except Exception as e1:
|
74 |
+
logger.warning(f"Помилка методу 1: {e1}")
|
75 |
+
|
76 |
+
# Метод 2: Явно вказуємо кодування
|
77 |
+
if not success:
|
78 |
+
try:
|
79 |
+
self.df = pd.read_csv(self.file_path, encoding='utf-8')
|
80 |
+
logger.info("Метод 2 (utf-8) успішний")
|
81 |
+
success = True
|
82 |
+
except Exception as e2:
|
83 |
+
logger.warning(f"Помилка методу 2: {e2}")
|
84 |
+
|
85 |
+
# Метод 3: Альтернативне кодування
|
86 |
+
if not success:
|
87 |
+
try:
|
88 |
+
self.df = pd.read_csv(self.file_path, encoding='latin1')
|
89 |
+
logger.info("Метод 3 (latin1) успішний")
|
90 |
+
success = True
|
91 |
+
except Exception as e3:
|
92 |
+
logger.warning(f"Помилка методу 3: {e3}")
|
93 |
+
|
94 |
+
# Метод 4: Читаємо вміст файлу та використовуємо StringIO
|
95 |
+
if not success:
|
96 |
+
try:
|
97 |
+
with open(self.file_path, 'rb') as f:
|
98 |
+
content = f.read()
|
99 |
+
self.df = pd.read_csv(io.StringIO(content.decode('utf-8', errors='replace')))
|
100 |
+
logger.info("Метод 4 (StringIO з utf-8 і errors='replace') успішний")
|
101 |
+
success = True
|
102 |
+
except Exception as e4:
|
103 |
+
logger.warning(f"Помилка методу 4: {e4}")
|
104 |
+
|
105 |
+
# Метод 5: Спроба з latin1 і StringIO
|
106 |
+
if not success:
|
107 |
+
try:
|
108 |
+
with open(self.file_path, 'rb') as f:
|
109 |
+
content = f.read()
|
110 |
+
self.df = pd.read_csv(io.StringIO(content.decode('latin1', errors='replace')))
|
111 |
+
logger.info("Метод 5 (StringIO з latin1 і errors='replace') успішний")
|
112 |
+
success = True
|
113 |
+
except Exception as e5:
|
114 |
+
logger.warning(f"По��илка методу 5: {e5}")
|
115 |
+
|
116 |
+
if not success:
|
117 |
+
logger.error("Всі методи зчитування файлу невдалі")
|
118 |
+
return None
|
119 |
+
|
120 |
+
# Відображення наявних колонок для діагностики
|
121 |
+
print(f"Наявні колонки: {self.df.columns.tolist()}")
|
122 |
+
print(f"Кількість рядків: {len(self.df)}")
|
123 |
+
logger.info(f"Наявні колонки: {self.df.columns.tolist()}")
|
124 |
+
logger.info(f"Кількість рядків: {len(self.df)}")
|
125 |
+
|
126 |
+
# Обробка дат
|
127 |
+
self._process_dates()
|
128 |
+
|
129 |
+
# Очищення та підготовка даних
|
130 |
+
self._clean_data()
|
131 |
+
|
132 |
+
# Перевіряємо наявність індексів для цього CSV
|
133 |
+
if self.file_hash:
|
134 |
+
# Перевіряємо та оновлюємо метадані файлу
|
135 |
+
self._check_indices_metadata()
|
136 |
+
|
137 |
+
logger.info(f"Успішно завантажено {len(self.df)} записів")
|
138 |
+
print(f"Успішно завантажено {len(self.df)} записів")
|
139 |
+
return self.df
|
140 |
+
|
141 |
+
except Exception as e:
|
142 |
+
logger.error(f"Помилка при завантаженні CSV-файлу: {e}")
|
143 |
+
import traceback
|
144 |
+
error_details = traceback.format_exc()
|
145 |
+
print(f"Помилка при завантаженні CSV-файлу: {e}")
|
146 |
+
print(f"Деталі помилки: {error_details}")
|
147 |
+
logger.error(error_details)
|
148 |
+
return None
|
149 |
+
|
150 |
+
def _generate_file_hash(self):
|
151 |
+
"""
|
152 |
+
Генерує хеш для CSV файлу на основі його вмісту
|
153 |
+
|
154 |
+
Returns:
|
155 |
+
str: Хеш файлу або None у випадку помилки
|
156 |
+
"""
|
157 |
+
try:
|
158 |
+
# Читаємо файл блоками для ефективного хешування великих файлів
|
159 |
+
sha256 = hashlib.sha256()
|
160 |
+
with open(self.file_path, "rb") as f:
|
161 |
+
for byte_block in iter(lambda: f.read(4096), b""):
|
162 |
+
sha256.update(byte_block)
|
163 |
+
|
164 |
+
return sha256.hexdigest()
|
165 |
+
|
166 |
+
except Exception as e:
|
167 |
+
logger.error(f"Помилка при генерації хешу CSV: {e}")
|
168 |
+
return None
|
169 |
+
|
170 |
+
def _check_indices_metadata(self):
|
171 |
+
"""
|
172 |
+
Перевіряє наявність індексів для поточного CSV файлу
|
173 |
+
та оновлює метадані при необхідності.
|
174 |
+
"""
|
175 |
+
try:
|
176 |
+
import json
|
177 |
+
from pathlib import Path
|
178 |
+
|
179 |
+
# Шлях до директорії індексів
|
180 |
+
indices_dir = Path("temp/indices")
|
181 |
+
|
182 |
+
if not indices_dir.exists():
|
183 |
+
return
|
184 |
+
|
185 |
+
# Отримання списку піддиректорій з індексами
|
186 |
+
subdirs = [d for d in indices_dir.iterdir() if d.is_dir()]
|
187 |
+
if not subdirs:
|
188 |
+
return
|
189 |
+
|
190 |
+
# Перевіряємо кожну директорію на відповідність хешу
|
191 |
+
for directory in subdirs:
|
192 |
+
metadata_path = directory / "metadata.json"
|
193 |
+
if metadata_path.exists():
|
194 |
+
try:
|
195 |
+
with open(metadata_path, "r", encoding="utf-8") as f:
|
196 |
+
metadata = json.load(f)
|
197 |
+
|
198 |
+
# Якщо знайдено відповідні індекси, додаємо інформацію про колонки
|
199 |
+
if "csv_hash" in metadata and metadata["csv_hash"] == self.file_hash:
|
200 |
+
# Оновлюємо інформацію про колонки
|
201 |
+
metadata["columns"] = self.df.columns.tolist()
|
202 |
+
metadata["rows_count"] = len(self.df)
|
203 |
+
metadata["last_used"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
204 |
+
|
205 |
+
# Оновлюємо файл метаданих
|
206 |
+
with open(metadata_path, "w", encoding="utf-8") as f:
|
207 |
+
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
208 |
+
|
209 |
+
logger.info(f"Оновлено метадані для індексів: {directory}")
|
210 |
+
break
|
211 |
+
except Exception as md_err:
|
212 |
+
logger.warning(f"Помилка при перевірці метаданих {metadata_path}: {md_err}")
|
213 |
+
except Exception as e:
|
214 |
+
logger.warning(f"Помилка при перевірці метаданих індексів: {e}")
|
215 |
+
|
216 |
+
def _check_required_columns(self):
|
217 |
+
"""
|
218 |
+
Перевірка наявності необхідних колонок у CSV-файлі.
|
219 |
+
|
220 |
+
Returns:
|
221 |
+
bool: True, якщо всі необхідні колонки присутні
|
222 |
+
"""
|
223 |
+
# Основні колонки, які очікуються у файлі Jira
|
224 |
+
basic_columns = ['Summary', 'Issue key', 'Status', 'Issue Type', 'Priority', 'Created', 'Updated']
|
225 |
+
|
226 |
+
# Альтернативні назви колонок
|
227 |
+
alternative_columns = {
|
228 |
+
'Summary': ['Summary', 'Короткий опис'],
|
229 |
+
'Issue key': ['Issue key', 'Key', 'Ключ'],
|
230 |
+
'Status': ['Status', 'Статус'],
|
231 |
+
'Issue Type': ['Issue Type', 'Type', 'Тип'],
|
232 |
+
'Priority': ['Priority', 'Пріоритет'],
|
233 |
+
'Created': ['Created', 'Створено'],
|
234 |
+
'Updated': ['Updated', 'Оновлено']
|
235 |
+
}
|
236 |
+
|
237 |
+
# Перевірка наявності колонок
|
238 |
+
missing_columns = []
|
239 |
+
|
240 |
+
for col in basic_columns:
|
241 |
+
found = False
|
242 |
+
|
243 |
+
# Перевірка основної назви
|
244 |
+
if col in self.df.columns:
|
245 |
+
found = True
|
246 |
+
else:
|
247 |
+
# Перевірка альтернативних назв
|
248 |
+
for alt_col in alternative_columns.get(col, []):
|
249 |
+
if alt_col in self.df.columns:
|
250 |
+
# Перейменування колонки до стандартного імені
|
251 |
+
self.df.rename(columns={alt_col: col}, inplace=True)
|
252 |
+
found = True
|
253 |
+
break
|
254 |
+
|
255 |
+
if not found:
|
256 |
+
missing_columns.append(col)
|
257 |
+
|
258 |
+
if missing_columns:
|
259 |
+
logger.warning(f"Відсутні колонки: {', '.join(missing_columns)}")
|
260 |
+
print(f"Відсутні колонки: {', '.join(missing_columns)}")
|
261 |
+
return False
|
262 |
+
|
263 |
+
return True
|
264 |
+
|
265 |
+
def _process_dates(self):
|
266 |
+
"""
|
267 |
+
Обробка дат у DataFrame.
|
268 |
+
"""
|
269 |
+
try:
|
270 |
+
# Перетворення колонок з датами
|
271 |
+
date_columns = ['Created', 'Updated', 'Resolved', 'Due Date']
|
272 |
+
|
273 |
+
for col in date_columns:
|
274 |
+
if col in self.df.columns:
|
275 |
+
try:
|
276 |
+
self.df[col] = pd.to_datetime(self.df[col], errors='coerce')
|
277 |
+
print(f"Колонку {col} успішно конвертовано до datetime")
|
278 |
+
except Exception as e:
|
279 |
+
logger.warning(f"Не вдалося конвертувати колонку {col} до datetime: {e}")
|
280 |
+
print(f"Не вдалося конвертувати колонку {col} до datetime: {e}")
|
281 |
+
|
282 |
+
except Exception as e:
|
283 |
+
logger.error(f"Помилка при обробці дат: {e}")
|
284 |
+
print(f"Помилка при обробці дат: {e}")
|
285 |
+
|
286 |
+
def _clean_data(self):
|
287 |
+
"""
|
288 |
+
Очищення та підготовка даних.
|
289 |
+
"""
|
290 |
+
try:
|
291 |
+
# Видалення порожніх рядків
|
292 |
+
if 'Issue key' in self.df.columns:
|
293 |
+
self.df.dropna(subset=['Issue key'], inplace=True)
|
294 |
+
print(f"Видалено порожні рядки за колонкою 'Issue key'")
|
295 |
+
|
296 |
+
# Додаткова обробка даних
|
297 |
+
if 'Status' in self.df.columns:
|
298 |
+
self.df['Status'] = self.df['Status'].fillna('Unknown')
|
299 |
+
print(f"Заповнено відсутні значення в колонці 'Status'")
|
300 |
+
|
301 |
+
if 'Priority' in self.df.columns:
|
302 |
+
self.df['Priority'] = self.df['Priority'].fillna('Not set')
|
303 |
+
print(f"Заповнено відсутні значення в колонці 'Priority'")
|
304 |
+
|
305 |
+
# Створення додаткових колонок для аналізу
|
306 |
+
if 'Created' in self.df.columns and pd.api.types.is_datetime64_dtype(self.df['Created']):
|
307 |
+
self.df['Created_Date'] = self.df['Created'].dt.date
|
308 |
+
self.df['Created_Month'] = self.df['Created'].dt.to_period('M')
|
309 |
+
print(f"Створено додаткові колонки для дат створення")
|
310 |
+
|
311 |
+
if 'Updated' in self.df.columns and pd.api.types.is_datetime64_dtype(self.df['Updated']):
|
312 |
+
self.df['Updated_Date'] = self.df['Updated'].dt.date
|
313 |
+
self.df['Days_Since_Update'] = (datetime.now() - self.df['Updated']).dt.days
|
314 |
+
print(f"Створено додаткові колонки для дат оновлення")
|
315 |
+
|
316 |
+
except Exception as e:
|
317 |
+
logger.error(f"Помилка при очищенні даних: {e}")
|
318 |
+
print(f"Помилка при очищенні даних: {e}")
|
319 |
+
|
320 |
+
def export_to_csv(self, output_path=None):
|
321 |
+
"""
|
322 |
+
Експорт оброблених даних у CSV-файл.
|
323 |
+
|
324 |
+
Args:
|
325 |
+
output_path (str): Шлях для збереження файлу. Якщо None, створюється автоматично.
|
326 |
+
|
327 |
+
Returns:
|
328 |
+
str: Шлях до збереженого файлу або None у випадку помилки
|
329 |
+
"""
|
330 |
+
if self.df is None:
|
331 |
+
logger.error("Немає даних для експорту")
|
332 |
+
return None
|
333 |
+
|
334 |
+
try:
|
335 |
+
if output_path is None:
|
336 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
337 |
+
output_dir = Path("exported_data")
|
338 |
+
output_dir.mkdir(exist_ok=True)
|
339 |
+
output_path = output_dir / f"jira_data_{timestamp}.csv"
|
340 |
+
|
341 |
+
self.df.to_csv(output_path, index=False, encoding='utf-8')
|
342 |
+
logger.info(f"Дані успішно експортовано у {output_path}")
|
343 |
+
return str(output_path)
|
344 |
+
|
345 |
+
except Exception as e:
|
346 |
+
logger.error(f"Помилка при експорті даних: {e}")
|
347 |
+
return None
|
modules/data_import/jira_api.py
ADDED
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import json
|
3 |
+
import pandas as pd
|
4 |
+
import logging
|
5 |
+
import requests
|
6 |
+
from datetime import datetime, timedelta
|
7 |
+
from pathlib import Path
|
8 |
+
from jira import JIRA
|
9 |
+
import urllib3
|
10 |
+
|
11 |
+
logger = logging.getLogger(__name__)
|
12 |
+
|
13 |
+
class JiraConnector:
|
14 |
+
"""
|
15 |
+
Клас для взаємодії з API Jira та отримання даних
|
16 |
+
"""
|
17 |
+
def __init__(self, jira_url, jira_username, jira_api_token):
|
18 |
+
"""
|
19 |
+
Ініціалізація з'єднання з Jira.
|
20 |
+
|
21 |
+
Args:
|
22 |
+
jira_url (str): URL Jira сервера
|
23 |
+
jira_username (str): Ім'я користувача (email)
|
24 |
+
jira_api_token (str): API токен
|
25 |
+
"""
|
26 |
+
self.jira_url = jira_url
|
27 |
+
self.jira_username = jira_username
|
28 |
+
self.jira_api_token = jira_api_token
|
29 |
+
self.jira = self._connect()
|
30 |
+
|
31 |
+
def _connect(self):
|
32 |
+
"""
|
33 |
+
Підключення до Jira API.
|
34 |
+
|
35 |
+
Returns:
|
36 |
+
jira.JIRA: Об'єкт для взаємодії з Jira або None у випадку помилки
|
37 |
+
"""
|
38 |
+
try:
|
39 |
+
jira = JIRA(
|
40 |
+
server=self.jira_url,
|
41 |
+
basic_auth=(self.jira_username, self.jira_api_token),
|
42 |
+
options={'timeout': 30}
|
43 |
+
)
|
44 |
+
logger.info("Успішне підключення до Jira")
|
45 |
+
return jira
|
46 |
+
except Exception as e:
|
47 |
+
logger.error(f"Помилка підключення до Jira: {e}")
|
48 |
+
return None
|
49 |
+
|
50 |
+
def get_project_issues(self, project_key, max_results=500):
|
51 |
+
"""
|
52 |
+
Отримання тікетів проекту.
|
53 |
+
|
54 |
+
Args:
|
55 |
+
project_key (str): Ключ проекту Jira
|
56 |
+
max_results (int): Максимальна кількість тікетів для отримання
|
57 |
+
|
58 |
+
Returns:
|
59 |
+
list: Список тікетів або [] у випадку помилки
|
60 |
+
"""
|
61 |
+
try:
|
62 |
+
if self.jira is None:
|
63 |
+
logger.error("Немає з'єднання з Jira")
|
64 |
+
return []
|
65 |
+
|
66 |
+
jql = f'project = {project_key} ORDER BY updated DESC'
|
67 |
+
logger.info(f"Виконання JQL запиту: {jql}")
|
68 |
+
|
69 |
+
issues = self.jira.search_issues(
|
70 |
+
jql,
|
71 |
+
maxResults=max_results,
|
72 |
+
fields="summary,status,issuetype,priority,labels,components,created,updated,assignee,reporter,description,comment"
|
73 |
+
)
|
74 |
+
|
75 |
+
logger.info(f"Отримано {len(issues)} тікетів для проекту {project_key}")
|
76 |
+
return issues
|
77 |
+
|
78 |
+
except Exception as e:
|
79 |
+
logger.error(f"Помилка отримання тікетів: {e}")
|
80 |
+
return []
|
81 |
+
|
82 |
+
def get_board_issues(self, board_id, project_key, max_results=500):
|
83 |
+
"""
|
84 |
+
Отримання тікетів дошки.
|
85 |
+
|
86 |
+
Args:
|
87 |
+
board_id (int): ID дошки Jira
|
88 |
+
project_key (str): Ключ проекту для фільтрації
|
89 |
+
max_results (int): Максимальна кількість тікетів для отримання
|
90 |
+
|
91 |
+
Returns:
|
92 |
+
list: Список тікетів або [] у випадку помилки
|
93 |
+
"""
|
94 |
+
try:
|
95 |
+
if self.jira is None:
|
96 |
+
logger.error("Немає з'єднання з Jira")
|
97 |
+
return []
|
98 |
+
|
99 |
+
issues = []
|
100 |
+
start_at = 0
|
101 |
+
logger.info(f"Отримання тікетів з дошки ID: {board_id}, проект: {project_key}...")
|
102 |
+
|
103 |
+
while True:
|
104 |
+
logger.info(f" Отримання тікетів (з {start_at}, максимум 100)...")
|
105 |
+
|
106 |
+
batch = self.jira.search_issues(
|
107 |
+
f'project = {project_key} ORDER BY updated DESC',
|
108 |
+
startAt=start_at,
|
109 |
+
maxResults=100,
|
110 |
+
fields="summary,status,issuetype,priority,labels,components,created,updated,assignee,reporter,description,comment"
|
111 |
+
)
|
112 |
+
|
113 |
+
if not batch:
|
114 |
+
break
|
115 |
+
|
116 |
+
issues.extend(batch)
|
117 |
+
start_at += len(batch)
|
118 |
+
logger.info(f" Отримано {len(batch)} тікетів, загалом {len(issues)}")
|
119 |
+
|
120 |
+
if len(batch) < 100 or len(issues) >= max_results:
|
121 |
+
break
|
122 |
+
|
123 |
+
logger.info(f"Загалом отримано {len(issues)} тікетів з дошки {board_id}")
|
124 |
+
return issues
|
125 |
+
|
126 |
+
except Exception as e:
|
127 |
+
logger.error(f"Помилка отримання тікетів дошки: {e}")
|
128 |
+
logger.error(f"Деталі помилки: {str(e)}")
|
129 |
+
return []
|
130 |
+
|
131 |
+
def export_issues_to_csv(self, issues, filepath):
|
132 |
+
"""
|
133 |
+
Експорт тікетів у CSV-файл.
|
134 |
+
|
135 |
+
Args:
|
136 |
+
issues (list): Список тікетів Jira
|
137 |
+
filepath (str): Шлях для збереження CSV-файлу
|
138 |
+
|
139 |
+
Returns:
|
140 |
+
pandas.DataFrame: DataFrame з даними або None у випадку помилки
|
141 |
+
"""
|
142 |
+
if not issues:
|
143 |
+
logger.warning("Немає тікетів для експорту")
|
144 |
+
return None
|
145 |
+
|
146 |
+
try:
|
147 |
+
data = []
|
148 |
+
|
149 |
+
for issue in issues:
|
150 |
+
# Визначення даних тікета з коректною обробкою потенційно відсутніх полів
|
151 |
+
issue_data = {
|
152 |
+
'Issue key': issue.key,
|
153 |
+
'Summary': getattr(issue.fields, 'summary', None),
|
154 |
+
'Status': getattr(issue.fields.status, 'name', None) if hasattr(issue.fields, 'status') else None,
|
155 |
+
'Issue Type': getattr(issue.fields.issuetype, 'name', None) if hasattr(issue.fields, 'issuetype') else None,
|
156 |
+
'Priority': getattr(issue.fields.priority, 'name', None) if hasattr(issue.fields, 'priority') else None,
|
157 |
+
'Components': ','.join([c.name for c in issue.fields.components]) if hasattr(issue.fields, 'components') and issue.fields.components else '',
|
158 |
+
'Labels': ','.join(issue.fields.labels) if hasattr(issue.fields, 'labels') and issue.fields.labels else '',
|
159 |
+
'Created': getattr(issue.fields, 'created', None),
|
160 |
+
'Updated': getattr(issue.fields, 'updated', None),
|
161 |
+
'Assignee': getattr(issue.fields.assignee, 'displayName', None) if hasattr(issue.fields, 'assignee') and issue.fields.assignee else None,
|
162 |
+
'Reporter': getattr(issue.fields.reporter, 'displayName', None) if hasattr(issue.fields, 'reporter') and issue.fields.reporter else None,
|
163 |
+
'Description': getattr(issue.fields, 'description', None),
|
164 |
+
'Comments Count': len(issue.fields.comment.comments) if hasattr(issue.fields, 'comment') and hasattr(issue.fields.comment, 'comments') else 0
|
165 |
+
}
|
166 |
+
|
167 |
+
# Додаємо коментарі, якщо вони є
|
168 |
+
if hasattr(issue.fields, 'comment') and hasattr(issue.fields.comment, 'comments'):
|
169 |
+
for i, comment in enumerate(issue.fields.comment.comments[:3]): # Беремо перші 3 коментарі
|
170 |
+
issue_data[f'Comment {i+1}'] = comment.body
|
171 |
+
|
172 |
+
data.append(issue_data)
|
173 |
+
|
174 |
+
# Створення DataFrame
|
175 |
+
df = pd.DataFrame(data)
|
176 |
+
|
177 |
+
# Збереження в CSV
|
178 |
+
df.to_csv(filepath, index=False, encoding='utf-8')
|
179 |
+
logger.info(f"Дані експортовано у {filepath}")
|
180 |
+
|
181 |
+
return df
|
182 |
+
|
183 |
+
except Exception as e:
|
184 |
+
logger.error(f"Помилка при експорті даних: {e}")
|
185 |
+
return None
|
186 |
+
|
187 |
+
def get_project_info(self, project_key):
|
188 |
+
"""
|
189 |
+
Отримання інформації про проект.
|
190 |
+
|
191 |
+
Args:
|
192 |
+
project_key (str): Ключ проекту Jira
|
193 |
+
|
194 |
+
Returns:
|
195 |
+
dict: Інформація про проект або None у випадку помилки
|
196 |
+
"""
|
197 |
+
try:
|
198 |
+
if self.jira is None:
|
199 |
+
logger.error("Немає з'єднання з Jira")
|
200 |
+
return None
|
201 |
+
|
202 |
+
project = self.jira.project(project_key)
|
203 |
+
|
204 |
+
project_info = {
|
205 |
+
'key': project.key,
|
206 |
+
'name': project.name,
|
207 |
+
'lead': project.lead.displayName,
|
208 |
+
'description': project.description,
|
209 |
+
'url': f"{self.jira_url}/projects/{project.key}"
|
210 |
+
}
|
211 |
+
|
212 |
+
logger.info(f"Отримано інформацію про проект {project_key}")
|
213 |
+
return project_info
|
214 |
+
|
215 |
+
except Exception as e:
|
216 |
+
logger.error(f"Помилка отримання інформації про проект: {e}")
|
217 |
+
return None
|
218 |
+
|
219 |
+
def get_boards_list(self, project_key=None):
|
220 |
+
"""
|
221 |
+
Отримання списку дошок.
|
222 |
+
|
223 |
+
Args:
|
224 |
+
project_key (str): Ключ проекту для фільтрації (необов'язково)
|
225 |
+
|
226 |
+
Returns:
|
227 |
+
list: Список дошок або [] у випадку помилки
|
228 |
+
"""
|
229 |
+
try:
|
230 |
+
if self.jira is None:
|
231 |
+
logger.error("Немає з'єднання з Jira")
|
232 |
+
return []
|
233 |
+
|
234 |
+
# Отримання всіх дошок
|
235 |
+
all_boards = self.jira.boards()
|
236 |
+
|
237 |
+
# Фільтрація за проектом, якщо вказано
|
238 |
+
if project_key:
|
239 |
+
boards = []
|
240 |
+
for board in all_boards:
|
241 |
+
# Перевірка, чи дошка належить до вказаного проекту
|
242 |
+
if hasattr(board, 'location') and hasattr(board.location, 'projectKey') and board.location.projectKey == project_key:
|
243 |
+
boards.append(board)
|
244 |
+
# Або якщо назва дошки містить ключ проекту
|
245 |
+
elif project_key in board.name:
|
246 |
+
boards.append(board)
|
247 |
+
else:
|
248 |
+
boards = all_boards
|
249 |
+
|
250 |
+
# Формування результату
|
251 |
+
result = []
|
252 |
+
for board in boards:
|
253 |
+
board_info = {
|
254 |
+
'id': board.id,
|
255 |
+
'name': board.name,
|
256 |
+
'type': board.type
|
257 |
+
}
|
258 |
+
|
259 |
+
if hasattr(board, 'location'):
|
260 |
+
board_info['project_key'] = getattr(board.location, 'projectKey', None)
|
261 |
+
board_info['project_name'] = getattr(board.location, 'projectName', None)
|
262 |
+
|
263 |
+
result.append(board_info)
|
264 |
+
|
265 |
+
logger.info(f"Отримано {len(result)} дошок")
|
266 |
+
return result
|
267 |
+
|
268 |
+
except Exception as e:
|
269 |
+
logger.error(f"Помилка отримання списку дошок: {e}")
|
270 |
+
return []
|
271 |
+
|
272 |
+
def get_issue_details(self, issue_key):
|
273 |
+
"""
|
274 |
+
Отримання детальної інформації про тікет.
|
275 |
+
|
276 |
+
Args:
|
277 |
+
issue_key (str): Ключ тікета
|
278 |
+
|
279 |
+
Returns:
|
280 |
+
dict: Детальна інформація про тікет або None у випадку помилки
|
281 |
+
"""
|
282 |
+
try:
|
283 |
+
if self.jira is None:
|
284 |
+
logger.error("Немає з'єднання з Jira")
|
285 |
+
return None
|
286 |
+
|
287 |
+
issue = self.jira.issue(issue_key)
|
288 |
+
|
289 |
+
# Базова інформація
|
290 |
+
issue_details = {
|
291 |
+
'key': issue.key,
|
292 |
+
'summary': issue.fields.summary,
|
293 |
+
'status': issue.fields.status.name,
|
294 |
+
'issue_type': issue.fields.issuetype.name,
|
295 |
+
'priority': issue.fields.priority.name if hasattr(issue.fields, 'priority') and issue.fields.priority else None,
|
296 |
+
'created': issue.fields.created,
|
297 |
+
'updated': issue.fields.updated,
|
298 |
+
'description': issue.fields.description,
|
299 |
+
'assignee': issue.fields.assignee.displayName if hasattr(issue.fields, 'assignee') and issue.fields.assignee else None,
|
300 |
+
'reporter': issue.fields.reporter.displayName if hasattr(issue.fields, 'reporter') and issue.fields.reporter else None,
|
301 |
+
'url': f"{self.jira_url}/browse/{issue.key}"
|
302 |
+
}
|
303 |
+
|
304 |
+
# Додаємо коментарі
|
305 |
+
comments = []
|
306 |
+
if hasattr(issue.fields, 'comment') and hasattr(issue.fields.comment, 'comments'):
|
307 |
+
for comment in issue.fields.comment.comments:
|
308 |
+
comments.append({
|
309 |
+
'author': comment.author.displayName,
|
310 |
+
'created': comment.created,
|
311 |
+
'body': comment.body
|
312 |
+
})
|
313 |
+
|
314 |
+
issue_details['comments'] = comments
|
315 |
+
|
316 |
+
# Додаємо історію змін
|
317 |
+
changelog = self.jira.issue(issue_key, expand='changelog').changelog
|
318 |
+
history = []
|
319 |
+
|
320 |
+
for history_item in changelog.histories:
|
321 |
+
item_info = {
|
322 |
+
'author': history_item.author.displayName,
|
323 |
+
'created': history_item.created,
|
324 |
+
'changes': []
|
325 |
+
}
|
326 |
+
|
327 |
+
for item in history_item.items:
|
328 |
+
item_info['changes'].append({
|
329 |
+
'field': item.field,
|
330 |
+
'from_value': item.fromString,
|
331 |
+
'to_value': item.toString
|
332 |
+
})
|
333 |
+
|
334 |
+
history.append(item_info)
|
335 |
+
|
336 |
+
issue_details['history'] = history
|
337 |
+
|
338 |
+
logger.info(f"Отримано детальну інформацію про тікет {issue_key}")
|
339 |
+
return issue_details
|
340 |
+
|
341 |
+
except Exception as e:
|
342 |
+
logger.error(f"Помилка отримання деталей тікета: {e}")
|
343 |
+
return None
|
344 |
+
|
345 |
+
@staticmethod
|
346 |
+
def test_connection(url, username, api_token):
|
347 |
+
"""
|
348 |
+
Тестування підключення до Jira.
|
349 |
+
|
350 |
+
Args:
|
351 |
+
url (str): URL Jira сервера
|
352 |
+
username (str): Ім'я користувача (email)
|
353 |
+
api_token (str): API токен
|
354 |
+
|
355 |
+
Returns:
|
356 |
+
bool: True, якщо підключення успішне, False у іншому випадку
|
357 |
+
"""
|
358 |
+
logger.info(f"Тестування підключення до Jira: {url}")
|
359 |
+
logger.info(f"Користувач: {username}")
|
360 |
+
|
361 |
+
# Спроба прямого HTTP запиту до сервера
|
362 |
+
try:
|
363 |
+
logger.info("Спроба прямого HTTP запиту до сервера...")
|
364 |
+
|
365 |
+
response = requests.get(
|
366 |
+
f"{url}/rest/api/2/serverInfo",
|
367 |
+
auth=(username, api_token),
|
368 |
+
timeout=10,
|
369 |
+
verify=True # Змініть на False, якщо у вас самопідписаний сертифікат
|
370 |
+
)
|
371 |
+
|
372 |
+
logger.info(f"Статус відповіді: {response.status_code}")
|
373 |
+
|
374 |
+
if response.status_code == 200:
|
375 |
+
logger.info(f"Відповідь: {response.text[:200]}...")
|
376 |
+
return True
|
377 |
+
else:
|
378 |
+
logger.error(f"Помилка: {response.text}")
|
379 |
+
return False
|
380 |
+
|
381 |
+
except Exception as e:
|
382 |
+
logger.error(f"Помилка HTTP запиту: {type(e).__name__}: {str(e)}")
|
383 |
+
logger.error(f"Деталі винятку: {repr(e)}")
|
384 |
+
return False
|
modules/data_management/data_manager.py
ADDED
@@ -0,0 +1,500 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import shutil
|
3 |
+
import logging
|
4 |
+
import pandas as pd
|
5 |
+
import hashlib
|
6 |
+
from pathlib import Path
|
7 |
+
from datetime import datetime
|
8 |
+
from modules.data_management.session_manager import SessionManager
|
9 |
+
|
10 |
+
logger = logging.getLogger(__name__)
|
11 |
+
|
12 |
+
class DataManager:
|
13 |
+
"""
|
14 |
+
Менеджер даних для роботи з файлами CSV та їх обробки.
|
15 |
+
"""
|
16 |
+
def __init__(self, current_data_dir="current_data", session_manager=None):
|
17 |
+
"""
|
18 |
+
Ініціалізація менеджера даних.
|
19 |
+
|
20 |
+
Args:
|
21 |
+
current_data_dir (str): Директорія з локальними файлами даних
|
22 |
+
session_manager (SessionManager, optional): Менеджер сесій або None для створення нового
|
23 |
+
"""
|
24 |
+
self.current_data_dir = Path(current_data_dir)
|
25 |
+
self.current_data_dir.mkdir(exist_ok=True, parents=True)
|
26 |
+
|
27 |
+
# Ініціалізація менеджера сесій
|
28 |
+
self.session_manager = session_manager or SessionManager()
|
29 |
+
|
30 |
+
def get_local_files(self):
|
31 |
+
"""
|
32 |
+
Отримання списку локальних CSV-файлів.
|
33 |
+
|
34 |
+
Returns:
|
35 |
+
list: Список словників з інформацією про файли
|
36 |
+
"""
|
37 |
+
files_info = []
|
38 |
+
|
39 |
+
if not self.current_data_dir.exists():
|
40 |
+
logger.warning(f"Директорія {self.current_data_dir} не існує")
|
41 |
+
return files_info
|
42 |
+
|
43 |
+
for file_path in self.current_data_dir.glob("*.csv"):
|
44 |
+
try:
|
45 |
+
# Отримуємо базову інформацію про файл
|
46 |
+
stat = file_path.stat()
|
47 |
+
size_kb = stat.st_size / 1024
|
48 |
+
modified = datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
|
49 |
+
|
50 |
+
# Спроба зчитати перші рядки для отримання інформації про структуру
|
51 |
+
try:
|
52 |
+
df_preview = pd.read_csv(file_path, nrows=5)
|
53 |
+
rows_preview = len(df_preview)
|
54 |
+
columns_preview = len(df_preview.columns)
|
55 |
+
columns_list = df_preview.columns.tolist()
|
56 |
+
except Exception as e:
|
57 |
+
logger.warning(f"Не вдалося прочитати файл {file_path}: {e}")
|
58 |
+
rows_preview = "?"
|
59 |
+
columns_preview = "?"
|
60 |
+
columns_list = []
|
61 |
+
|
62 |
+
# Формуємо інформацію про файл
|
63 |
+
files_info.append({
|
64 |
+
"path": str(file_path),
|
65 |
+
"name": file_path.name,
|
66 |
+
"size_kb": round(size_kb, 2),
|
67 |
+
"modified": modified,
|
68 |
+
"rows_preview": rows_preview,
|
69 |
+
"columns_preview": columns_preview,
|
70 |
+
"columns_list": columns_list
|
71 |
+
})
|
72 |
+
except Exception as e:
|
73 |
+
logger.error(f"Помилка при обробці файлу {file_path}: {e}")
|
74 |
+
|
75 |
+
# Сортуємо за часом модифікації (від найновіших до найстаріших)
|
76 |
+
files_info.sort(key=lambda x: x["modified"], reverse=True)
|
77 |
+
|
78 |
+
return files_info
|
79 |
+
|
80 |
+
def validate_csv_file(self, file_path):
|
81 |
+
"""
|
82 |
+
Перевірка валідності CSV-файлу.
|
83 |
+
|
84 |
+
Args:
|
85 |
+
file_path (str): Шлях до файлу
|
86 |
+
|
87 |
+
Returns:
|
88 |
+
tuple: (is_valid, info_dict)
|
89 |
+
is_valid - True, якщо файл валідний
|
90 |
+
info_dict - словник з інформацією про файл
|
91 |
+
"""
|
92 |
+
if not Path(file_path).exists():
|
93 |
+
return False, {"error": f"Файл не знайдено: {file_path}"}
|
94 |
+
|
95 |
+
try:
|
96 |
+
# Отримуємо інформацію про файл
|
97 |
+
file_stat = Path(file_path).stat()
|
98 |
+
size_kb = file_stat.st_size / 1024
|
99 |
+
|
100 |
+
if size_kb == 0:
|
101 |
+
return False, {"error": "Файл порожній"}
|
102 |
+
|
103 |
+
# Спроба зчитати файл
|
104 |
+
df = pd.read_csv(file_path)
|
105 |
+
|
106 |
+
# Перевірка наявності очікуваних колонок
|
107 |
+
required_columns = ['Summary', 'Issue key', 'Status']
|
108 |
+
missing_columns = [col for col in required_columns if col not in df.columns]
|
109 |
+
|
110 |
+
if missing_columns:
|
111 |
+
return False, {
|
112 |
+
"error": f"Відсутні необхідні колонки: {', '.join(missing_columns)}",
|
113 |
+
"rows": len(df),
|
114 |
+
"columns": len(df.columns),
|
115 |
+
"columns_list": df.columns.tolist()
|
116 |
+
}
|
117 |
+
|
118 |
+
# Формуємо інформацію про файл
|
119 |
+
info = {
|
120 |
+
"rows": len(df),
|
121 |
+
"columns": len(df.columns),
|
122 |
+
"columns_list": df.columns.tolist(),
|
123 |
+
"size_kb": round(size_kb, 2),
|
124 |
+
"first_rows": df.head(5).to_dict('records')
|
125 |
+
}
|
126 |
+
|
127 |
+
return True, info
|
128 |
+
|
129 |
+
except Exception as e:
|
130 |
+
logger.error(f"Помилка при валідації CSV-файлу {file_path}: {e}")
|
131 |
+
return False, {"error": f"Помилка при читанні файлу: {str(e)}"}
|
132 |
+
|
133 |
+
def copy_files_to_session(self, session_id, file_paths_list):
|
134 |
+
"""
|
135 |
+
Копіювання вибраних файлів до сесії користувача.
|
136 |
+
|
137 |
+
Args:
|
138 |
+
session_id (str): Ідентифікатор сесії
|
139 |
+
file_paths_list (list): Список шляхів до файлів для копіювання
|
140 |
+
|
141 |
+
Returns:
|
142 |
+
list: Список скопійованих файлів у сесії
|
143 |
+
"""
|
144 |
+
session_data_dir = self.session_manager.get_session_data_dir(session_id)
|
145 |
+
if not session_data_dir:
|
146 |
+
logger.error(f"Не вдалося отримати директорію даних для сесії {session_id}")
|
147 |
+
return []
|
148 |
+
|
149 |
+
copied_files = []
|
150 |
+
|
151 |
+
for file_path in file_paths_list:
|
152 |
+
try:
|
153 |
+
source_path = Path(file_path)
|
154 |
+
if not source_path.exists():
|
155 |
+
logger.warning(f"Файл не знайдено: {file_path}")
|
156 |
+
continue
|
157 |
+
|
158 |
+
# Створюємо унікальне ім'я файлу в сесії
|
159 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
160 |
+
dest_filename = f"local_{timestamp}_{source_path.name}"
|
161 |
+
dest_path = session_data_dir / dest_filename
|
162 |
+
|
163 |
+
# Копіюємо файл
|
164 |
+
shutil.copyfile(source_path, dest_path)
|
165 |
+
|
166 |
+
# Додаємо інформацію про файл до сесії
|
167 |
+
if self.session_manager.add_data_file(
|
168 |
+
session_id,
|
169 |
+
str(dest_path),
|
170 |
+
file_type="local",
|
171 |
+
description=f"Local file: {source_path.name}"
|
172 |
+
):
|
173 |
+
copied_files.append(str(dest_path))
|
174 |
+
logger.info(f"Файл {source_path.name} скопійовано до сесії {session_id}")
|
175 |
+
|
176 |
+
except Exception as e:
|
177 |
+
logger.error(f"Помилка при копіюванні файлу {file_path} до сесії {session_id}: {e}")
|
178 |
+
|
179 |
+
return copied_files
|
180 |
+
|
181 |
+
def merge_dataframes(self, session_id, dataframes, output_name=None):
|
182 |
+
"""
|
183 |
+
Об'єднання кількох DataFrame та збереження результату в сесії.
|
184 |
+
|
185 |
+
Args:
|
186 |
+
session_id (str): Ідентифікатор сесії
|
187 |
+
dataframes (list): Список DataFrame для об'єднання
|
188 |
+
output_name (str, optional): Ім'я файлу для збереження результату
|
189 |
+
|
190 |
+
Returns:
|
191 |
+
tuple: (merged_df, output_path) - об'єднаний DataFrame та шлях до збереженого файлу
|
192 |
+
"""
|
193 |
+
if not dataframes:
|
194 |
+
logger.warning("Немає даних для об'єднання")
|
195 |
+
return None, None
|
196 |
+
|
197 |
+
try:
|
198 |
+
# Якщо є тільки один DataFrame, використовуємо його як базовий
|
199 |
+
if len(dataframes) == 1:
|
200 |
+
merged_df = dataframes[0].copy()
|
201 |
+
else:
|
202 |
+
# Об'єднуємо всі DataFrame по рядках з ігноруванням індексів
|
203 |
+
merged_df = pd.concat(dataframes, ignore_index=True)
|
204 |
+
|
205 |
+
# Видаляємо дублікати за ключовими колонками
|
206 |
+
if 'Issue key' in merged_df.columns:
|
207 |
+
merged_df.drop_duplicates(subset=['Issue key'], keep='first', inplace=True)
|
208 |
+
|
209 |
+
# Зберігаємо результат
|
210 |
+
output_path = self.session_manager.save_merged_data(session_id, merged_df, output_name)
|
211 |
+
|
212 |
+
return merged_df, output_path
|
213 |
+
|
214 |
+
except Exception as e:
|
215 |
+
logger.error(f"Помилка при об'єднанні даних: {e}")
|
216 |
+
return None, None
|
217 |
+
|
218 |
+
def load_data_from_files(self, session_id, file_paths_list):
|
219 |
+
"""
|
220 |
+
Завантаження даних з файлів у DataFrame.
|
221 |
+
|
222 |
+
Args:
|
223 |
+
session_id (str): Ідентифікатор сесії
|
224 |
+
file_paths_list (list): Список шляхів до файлів для завантаження
|
225 |
+
|
226 |
+
Returns:
|
227 |
+
list: Список кортежів (file_path, dataframe, success)
|
228 |
+
"""
|
229 |
+
results = []
|
230 |
+
|
231 |
+
for file_path in file_paths_list:
|
232 |
+
try:
|
233 |
+
# Перевіряємо, чи існує файл
|
234 |
+
if not Path(file_path).exists():
|
235 |
+
logger.warning(f"Файл не знайдено: {file_path}")
|
236 |
+
results.append((file_path, None, False))
|
237 |
+
continue
|
238 |
+
|
239 |
+
# Завантажуємо файл
|
240 |
+
df = pd.read_csv(file_path)
|
241 |
+
|
242 |
+
# Обробка дат
|
243 |
+
for date_col in ['Created', 'Updated', 'Resolved', 'Due Date']:
|
244 |
+
if date_col in df.columns:
|
245 |
+
df[date_col] = pd.to_datetime(df[date_col], format='%Y-%m-%dT%H:%M:%S', errors='coerce')
|
246 |
+
|
247 |
+
# Підготовка додаткових колонок для аналізу
|
248 |
+
if 'Created' in df.columns and pd.api.types.is_datetime64_dtype(df[date_col]):
|
249 |
+
df['Created_Date'] = df['Created'].dt.date
|
250 |
+
df['Created_Month'] = df['Created'].dt.to_period('M')
|
251 |
+
|
252 |
+
if 'Updated' in df.columns and pd.api.types.is_datetime64_dtype(df[date_col]):
|
253 |
+
df['Updated_Date'] = df['Updated'].dt.date
|
254 |
+
df['Days_Since_Update'] = (datetime.now() - df['Updated']).dt.days
|
255 |
+
|
256 |
+
results.append((file_path, df, True))
|
257 |
+
logger.info(f"Успішно завантажено файл {file_path}, {len(df)} рядків")
|
258 |
+
|
259 |
+
except Exception as e:
|
260 |
+
logger.error(f"Помилка при завантаженні файлу {file_path}: {e}")
|
261 |
+
results.append((file_path, None, False))
|
262 |
+
|
263 |
+
return results
|
264 |
+
|
265 |
+
def initialize_session_data(self, session_id, local_files, uploaded_file=None):
|
266 |
+
"""
|
267 |
+
Ініціалізація даних сесії з локальних та завантажених файлів.
|
268 |
+
|
269 |
+
Args:
|
270 |
+
session_id (str): Ідентифікатор сесії
|
271 |
+
local_files (list): Список шляхів до локальних файлів
|
272 |
+
uploaded_file (str, optional): Шлях до завантаженого файлу
|
273 |
+
|
274 |
+
Returns:
|
275 |
+
tuple: (success, result_info) - успішність операції та інформація про результат
|
276 |
+
"""
|
277 |
+
try:
|
278 |
+
# Копіюємо локальні файли до сесії
|
279 |
+
copied_files = self.copy_files_to_session(session_id, local_files)
|
280 |
+
|
281 |
+
# Додаємо завантажений файл, якщо він є
|
282 |
+
if uploaded_file and Path(uploaded_file).exists():
|
283 |
+
# Копіюємо файл до сесії
|
284 |
+
session_data_dir = self.session_manager.get_session_data_dir(session_id)
|
285 |
+
if not session_data_dir:
|
286 |
+
return False, {"error": "Не вдалося отримати директорію даних сесії"}
|
287 |
+
|
288 |
+
# Створюємо унікальне ім'я для завантаженого файлу
|
289 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
290 |
+
dest_filename = f"uploaded_{timestamp}_{Path(uploaded_file).name}"
|
291 |
+
dest_path = session_data_dir / dest_filename
|
292 |
+
|
293 |
+
# Копіюємо файл
|
294 |
+
shutil.copyfile(uploaded_file, dest_path)
|
295 |
+
|
296 |
+
# Додаємо інформацію про файл до сесії
|
297 |
+
self.session_manager.add_data_file(
|
298 |
+
session_id,
|
299 |
+
str(dest_path),
|
300 |
+
file_type="uploaded",
|
301 |
+
description=f"Uploaded file: {Path(uploaded_file).name}"
|
302 |
+
)
|
303 |
+
|
304 |
+
copied_files.append(str(dest_path))
|
305 |
+
|
306 |
+
# Якщо немає файлів для обробки, повертаємо помилку
|
307 |
+
if not copied_files:
|
308 |
+
return False, {"error": "Не вибрано жодного файлу для обробки"}
|
309 |
+
|
310 |
+
# Завантажуємо дані з усіх файлів
|
311 |
+
loaded_data = self.load_data_from_files(session_id, copied_files)
|
312 |
+
|
313 |
+
# Фільтруємо тільки успішно завантажені файли
|
314 |
+
valid_data = [(path, df) for path, df, success in loaded_data if success and df is not None]
|
315 |
+
|
316 |
+
if not valid_data:
|
317 |
+
return False, {"error": "Не вдалося завантажити жодного файлу"}
|
318 |
+
|
319 |
+
# Отримуємо список DataFrame
|
320 |
+
dataframes = [df for _, df in valid_data]
|
321 |
+
|
322 |
+
# Об'єднуємо дані
|
323 |
+
merged_df, output_path = self.merge_dataframes(
|
324 |
+
session_id,
|
325 |
+
dataframes,
|
326 |
+
output_name=f"merged_data_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
327 |
+
)
|
328 |
+
|
329 |
+
if merged_df is None or not output_path:
|
330 |
+
return False, {"error": "Не вдалося об'єднати дані"}
|
331 |
+
|
332 |
+
result_info = {
|
333 |
+
"merged_file": output_path,
|
334 |
+
"rows_count": len(merged_df),
|
335 |
+
"columns_count": len(merged_df.columns),
|
336 |
+
"source_files_count": len(valid_data),
|
337 |
+
"merged_df": merged_df # Передаємо DataFrame для подальшого використання
|
338 |
+
}
|
339 |
+
|
340 |
+
return True, result_info
|
341 |
+
|
342 |
+
except Exception as e:
|
343 |
+
logger.error(f"Помилка при ініціалізації даних сесії {session_id}: {e}")
|
344 |
+
return False, {"error": f"Помилка при ініціалізації даних: {str(e)}"}
|
345 |
+
|
346 |
+
def get_file_preview(self, file_path, max_rows=10):
|
347 |
+
"""
|
348 |
+
Отримання попереднього перегляду файлу CSV.
|
349 |
+
|
350 |
+
Args:
|
351 |
+
file_path (str): Шлях до файлу
|
352 |
+
max_rows (int): Максимальна кількість рядків для перегляду
|
353 |
+
|
354 |
+
Returns:
|
355 |
+
dict: Словник з інформацією про файл та його вмістом
|
356 |
+
"""
|
357 |
+
try:
|
358 |
+
if not Path(file_path).exists():
|
359 |
+
return {"error": f"Файл не знайдено: {file_path}"}
|
360 |
+
|
361 |
+
# Зчитуємо перші max_rows рядків
|
362 |
+
df = pd.read_csv(file_path, nrows=max_rows)
|
363 |
+
|
364 |
+
# Отримуємо інформацію про файл
|
365 |
+
file_stat = Path(file_path).stat()
|
366 |
+
size_kb = file_stat.st_size / 1024
|
367 |
+
modified = datetime.fromtimestamp(file_stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
|
368 |
+
|
369 |
+
# Підраховуємо загальну кількість рядків (обережно з великими файлами)
|
370 |
+
total_rows = sum(1 for _ in open(file_path, 'r')) - 1 # -1 для заголовка
|
371 |
+
|
372 |
+
# Формуємо результат
|
373 |
+
result = {
|
374 |
+
"filename": Path(file_path).name,
|
375 |
+
"path": file_path,
|
376 |
+
"size_kb": round(size_kb, 2),
|
377 |
+
"modified": modified,
|
378 |
+
"total_rows": total_rows,
|
379 |
+
"columns": df.columns.tolist(),
|
380 |
+
"columns_count": len(df.columns),
|
381 |
+
"preview_rows": df.to_dict('records')
|
382 |
+
}
|
383 |
+
|
384 |
+
return result
|
385 |
+
|
386 |
+
except Exception as e:
|
387 |
+
logger.error(f"Помилка при отриманні попереднього перегляду файлу {file_path}: {e}")
|
388 |
+
return {"error": f"Помилка при читанні файлу: {str(e)}"}
|
389 |
+
|
390 |
+
def cleanup_temp_data(self):
|
391 |
+
"""
|
392 |
+
Очищення тимчасових даних, крім файлів у папці current_data.
|
393 |
+
|
394 |
+
Returns:
|
395 |
+
dict: Інформація про результати очищення
|
396 |
+
"""
|
397 |
+
try:
|
398 |
+
import shutil
|
399 |
+
import os
|
400 |
+
from pathlib import Path
|
401 |
+
|
402 |
+
cleanup_stats = {
|
403 |
+
"temp_files_removed": 0,
|
404 |
+
"session_dirs_removed": 0,
|
405 |
+
"indices_dirs_removed": 0,
|
406 |
+
"reports_removed": 0,
|
407 |
+
"temp_directories": []
|
408 |
+
}
|
409 |
+
|
410 |
+
# Очищення тимчасових індексів
|
411 |
+
indices_dir = Path("temp/indices")
|
412 |
+
if indices_dir.exists():
|
413 |
+
for item in indices_dir.iterdir():
|
414 |
+
if item.is_dir():
|
415 |
+
try:
|
416 |
+
shutil.rmtree(item)
|
417 |
+
cleanup_stats["indices_dirs_removed"] += 1
|
418 |
+
except Exception as e:
|
419 |
+
logger.error(f"Помилка при видаленні директорії індексів {item}: {e}")
|
420 |
+
|
421 |
+
# Очищення тимчасових сесій
|
422 |
+
sessions_dir = Path("temp/sessions")
|
423 |
+
if sessions_dir.exists():
|
424 |
+
for item in sessions_dir.iterdir():
|
425 |
+
if item.is_dir():
|
426 |
+
try:
|
427 |
+
shutil.rmtree(item)
|
428 |
+
cleanup_stats["session_dirs_removed"] += 1
|
429 |
+
except Exception as e:
|
430 |
+
logger.error(f"Помилка при видаленні директорії сесій {item}: {e}")
|
431 |
+
|
432 |
+
# Очищення інших файлів у temp
|
433 |
+
temp_dir = Path("temp")
|
434 |
+
if temp_dir.exists():
|
435 |
+
for item in temp_dir.iterdir():
|
436 |
+
if item.is_file():
|
437 |
+
try:
|
438 |
+
item.unlink()
|
439 |
+
cleanup_stats["temp_files_removed"] += 1
|
440 |
+
except Exception as e:
|
441 |
+
logger.error(f"Помилка при видаленні файлу {item}: {e}")
|
442 |
+
|
443 |
+
# Очищення тимчасових звітів
|
444 |
+
reports_dir = Path("reports")
|
445 |
+
if reports_dir.exists():
|
446 |
+
reports_count = 0
|
447 |
+
|
448 |
+
# Видаляємо файли у головній директорії reports
|
449 |
+
for item in reports_dir.iterdir():
|
450 |
+
if item.is_file():
|
451 |
+
try:
|
452 |
+
item.unlink()
|
453 |
+
reports_count += 1
|
454 |
+
except Exception as e:
|
455 |
+
logger.error(f"Помилка при видаленні звіту {item}: {e}")
|
456 |
+
|
457 |
+
# Перевіряємо і очищаємо підпапку візуалізацій
|
458 |
+
viz_dir = reports_dir / "visualizations"
|
459 |
+
if viz_dir.exists():
|
460 |
+
for item in viz_dir.iterdir():
|
461 |
+
if item.is_file():
|
462 |
+
try:
|
463 |
+
item.unlink()
|
464 |
+
reports_count += 1
|
465 |
+
except Exception as e:
|
466 |
+
logger.error(f"Помилка при видаленні візуалізації {item}: {e}")
|
467 |
+
|
468 |
+
cleanup_stats["reports_removed"] = reports_count
|
469 |
+
|
470 |
+
# Запам'ятовуємо всі очищені директорії
|
471 |
+
cleanup_stats["temp_directories"] = ["temp/indices", "temp/sessions", "reports", "temp"]
|
472 |
+
|
473 |
+
# Створюємо наново всі необхідні директорії
|
474 |
+
for directory in ["temp", "temp/indices", "temp/sessions", "reports", "reports/visualizations"]:
|
475 |
+
Path(directory).mkdir(exist_ok=True, parents=True)
|
476 |
+
|
477 |
+
logger.info(f"Тимчасові дані успішно очищено: {cleanup_stats}")
|
478 |
+
return {
|
479 |
+
"success": True,
|
480 |
+
"stats": cleanup_stats
|
481 |
+
}
|
482 |
+
|
483 |
+
except Exception as e:
|
484 |
+
logger.error(f"Помилка при очищенні тимчасових даних: {e}")
|
485 |
+
return {
|
486 |
+
"success": False,
|
487 |
+
"error": str(e)
|
488 |
+
}
|
489 |
+
|
490 |
+
# Додано функцію в модуль для обробка дат
|
491 |
+
def safe_strftime(date_value, format_str="%Y-%m-%d"):
|
492 |
+
"""Безпечне форматування дати з обробкою None та NaT значень."""
|
493 |
+
import pandas as pd
|
494 |
+
|
495 |
+
if date_value is None or pd.isna(date_value):
|
496 |
+
return "Н/Д" # або будь-яке інше значення за замовчуванням
|
497 |
+
try:
|
498 |
+
return date_value.strftime(format_str)
|
499 |
+
except Exception:
|
500 |
+
return "Неправильна дата"
|
modules/data_management/data_processor.py
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from modules.data_management.unified_index_manager import UnifiedIndexManager
|
2 |
+
|
3 |
+
class DataProcessor:
|
4 |
+
def __init__(self):
|
5 |
+
# ... existing code ...
|
6 |
+
self.index_manager = UnifiedIndexManager()
|
7 |
+
|
8 |
+
def process_data(self, df, session_id):
|
9 |
+
# ... existing code ...
|
10 |
+
|
11 |
+
# Створюємо індекси
|
12 |
+
indices_result = self.index_manager.get_or_create_indices(df, session_id)
|
13 |
+
|
14 |
+
if "error" in indices_result:
|
15 |
+
logger.error(f"Помилка при створенні індексів: {indices_result['error']}")
|
16 |
+
else:
|
17 |
+
logger.info(f"Індекси успішно створено: {indices_result['indices_dir']}")
|
18 |
+
|
19 |
+
return {
|
20 |
+
"success": True,
|
21 |
+
"processed_data": df,
|
22 |
+
"indices_info": indices_result
|
23 |
+
}
|
modules/data_management/hash_utils.py
ADDED
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import hashlib
|
2 |
+
import pandas as pd
|
3 |
+
import json
|
4 |
+
import logging
|
5 |
+
|
6 |
+
logger = logging.getLogger(__name__)
|
7 |
+
|
8 |
+
def generate_data_hash(data, key_columns=None):
|
9 |
+
"""
|
10 |
+
Генерація хешу для даних.
|
11 |
+
|
12 |
+
Args:
|
13 |
+
data: DataFrame або словник з даними
|
14 |
+
key_columns (list, optional): Список ключових колонок для хешування
|
15 |
+
|
16 |
+
Returns:
|
17 |
+
str: Хеш даних
|
18 |
+
"""
|
19 |
+
try:
|
20 |
+
if isinstance(data, pd.DataFrame):
|
21 |
+
# Якщо передано DataFrame
|
22 |
+
if key_columns:
|
23 |
+
# Фільтруємо тільки наявні колонки
|
24 |
+
available_columns = [col for col in key_columns if col in data.columns]
|
25 |
+
|
26 |
+
if not available_columns:
|
27 |
+
# Якщо немає жодної ключової колонки, використовуємо всі дані
|
28 |
+
data_str = data.to_json(orient='records')
|
29 |
+
else:
|
30 |
+
# Інакше використовуємо тільки ключові колонки
|
31 |
+
data_str = data[available_columns].to_json(orient='records')
|
32 |
+
else:
|
33 |
+
# Якщо не вказано ключові колонки, використовуємо всі дані
|
34 |
+
data_str = data.to_json(orient='records')
|
35 |
+
elif isinstance(data, dict):
|
36 |
+
# Якщо передано словник
|
37 |
+
data_str = json.dumps(data, sort_keys=True)
|
38 |
+
else:
|
39 |
+
# Інакше конвертуємо в рядок
|
40 |
+
data_str = str(data)
|
41 |
+
|
42 |
+
# Створюємо хеш
|
43 |
+
hash_object = hashlib.sha256(data_str.encode())
|
44 |
+
data_hash = hash_object.hexdigest()
|
45 |
+
|
46 |
+
return data_hash
|
47 |
+
|
48 |
+
except Exception as e:
|
49 |
+
logger.error(f"Помилка при генерації хешу даних: {e}")
|
50 |
+
# У випадку помилки повертаємо None
|
51 |
+
return None
|
modules/data_management/index_manager.py
ADDED
@@ -0,0 +1,606 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import logging
|
3 |
+
import json
|
4 |
+
import shutil
|
5 |
+
from pathlib import Path
|
6 |
+
import pandas as pd
|
7 |
+
from datetime import datetime, timedelta
|
8 |
+
import hashlib
|
9 |
+
import uuid
|
10 |
+
import faiss
|
11 |
+
|
12 |
+
from modules.data_management.index_utils import validate_index_directory
|
13 |
+
from modules.data_management.index_utils import check_indexing_availability, initialize_embedding_model
|
14 |
+
from modules.data_management.hash_utils import generate_data_hash
|
15 |
+
from modules.data_management.index_utils import check_index_integrity
|
16 |
+
|
17 |
+
from modules.config.paths import INDICES_DIR
|
18 |
+
|
19 |
+
from modules.config.ai_settings import (
|
20 |
+
CHUNK_SIZE,
|
21 |
+
CHUNK_OVERLAP,
|
22 |
+
EXCLUDED_EMBED_METADATA_KEYS,
|
23 |
+
EXCLUDED_LLM_METADATA_KEYS
|
24 |
+
)
|
25 |
+
|
26 |
+
logger = logging.getLogger(__name__)
|
27 |
+
|
28 |
+
# Перевірка доступності модулів для індексування
|
29 |
+
INDEXING_AVAILABLE = check_indexing_availability()
|
30 |
+
INDEXING_MODULES = {
|
31 |
+
"VectorStoreIndex": None,
|
32 |
+
"StorageContext": None,
|
33 |
+
"SimpleDocumentStore": None,
|
34 |
+
"TokenTextSplitter": None,
|
35 |
+
"BM25Retriever": None,
|
36 |
+
"FaissVectorStore": None,
|
37 |
+
"Settings": None
|
38 |
+
}
|
39 |
+
|
40 |
+
|
41 |
+
|
42 |
+
def _generate_data_hash(self, df):
|
43 |
+
"""
|
44 |
+
Генерація хешу для DataFrame для ідентифікації унікальних даних.
|
45 |
+
|
46 |
+
Args:
|
47 |
+
df (pandas.DataFrame): DataFrame для хешування
|
48 |
+
|
49 |
+
Returns:
|
50 |
+
str: Хеш даних
|
51 |
+
"""
|
52 |
+
# Використовуємо основні колонки для хешування
|
53 |
+
key_columns = ['Issue key', 'Summary', 'Status', 'Issue Type', 'Created', 'Updated']
|
54 |
+
|
55 |
+
return generate_data_hash(df, key_columns)
|
56 |
+
|
57 |
+
class IndexManager:
|
58 |
+
"""
|
59 |
+
Менеджер для створення та управління індексами даних (FAISS, BM25).
|
60 |
+
"""
|
61 |
+
def __init__(self, base_indices_dir="temp/indices"):
|
62 |
+
"""
|
63 |
+
Ініціалізація менеджера індексів.
|
64 |
+
|
65 |
+
Args:
|
66 |
+
base_indices_dir (str): Базова директорія для зберігання індексів
|
67 |
+
"""
|
68 |
+
self.base_indices_dir = Path(base_indices_dir) if base_indices_dir else INDICES_DIR
|
69 |
+
self.base_indices_dir.mkdir(exist_ok=True, parents=True)
|
70 |
+
|
71 |
+
# Перевірка доступності модулів для індексування
|
72 |
+
self.indexing_available = INDEXING_AVAILABLE
|
73 |
+
if not self.indexing_available:
|
74 |
+
logger.warning("Функціональність індексування недоступна. Встановіть необхідні пакети.")
|
75 |
+
|
76 |
+
def create_indices_for_session(self, session_id, merged_df, indices_dir=None):
|
77 |
+
"""
|
78 |
+
Створення індексів для даних сесії.
|
79 |
+
|
80 |
+
Args:
|
81 |
+
session_id (str): Ідентифікатор сесії
|
82 |
+
merged_df (pandas.DataFrame): DataFrame з об'єднаними даними
|
83 |
+
indices_dir (str, optional): Директорія для збереження індексів.
|
84 |
+
Якщо None, використовується директорія сесії.
|
85 |
+
|
86 |
+
Returns:
|
87 |
+
dict: Інформація про створені індекси
|
88 |
+
"""
|
89 |
+
if not self.indexing_available:
|
90 |
+
return {"error": "Функціональність індексування недоступна. Встановіть необхідні пакети."}
|
91 |
+
|
92 |
+
try:
|
93 |
+
# Визначаємо директорію для індексів
|
94 |
+
indices_path = Path(indices_dir) if indices_dir else self.base_indices_dir / session_id
|
95 |
+
indices_path.mkdir(exist_ok=True, parents=True)
|
96 |
+
|
97 |
+
# Генеруємо хеш для даних
|
98 |
+
data_hash = self._generate_data_hash(merged_df)
|
99 |
+
|
100 |
+
# Перевіряємо, чи існують індекси для цих даних
|
101 |
+
existing_indices = self._find_indices_by_hash(data_hash)
|
102 |
+
|
103 |
+
if existing_indices:
|
104 |
+
return self._reuse_existing_indices(existing_indices, indices_path, session_id, data_hash, merged_df)
|
105 |
+
|
106 |
+
# Створюємо нові індекси
|
107 |
+
return self._create_new_indices(indices_path, session_id, data_hash, merged_df)
|
108 |
+
|
109 |
+
except Exception as e:
|
110 |
+
logger.error(f"Помилка при створенні індексів: {e}")
|
111 |
+
return {"error": f"Помилка при створенні індексів: {str(e)}"}
|
112 |
+
|
113 |
+
def _reuse_existing_indices(self, existing_indices, indices_path, session_id, data_hash, merged_df):
|
114 |
+
"""
|
115 |
+
Повторне використання існуючих індексів.
|
116 |
+
|
117 |
+
Args:
|
118 |
+
existing_indices (str): Шлях до існуючих індексів
|
119 |
+
indices_path (Path): Шлях для нових індексів
|
120 |
+
session_id (str): Ідентифікатор сесії
|
121 |
+
data_hash (str): Хеш даних
|
122 |
+
merged_df (pandas.DataFrame): DataFrame з даними
|
123 |
+
|
124 |
+
Returns:
|
125 |
+
dict: Інформація про скопійовані індекси
|
126 |
+
"""
|
127 |
+
logger.info(f"Знайдено існуючі індекси для даних з хешем {data_hash}")
|
128 |
+
|
129 |
+
try:
|
130 |
+
# Спочатку очищаємо цільову директорію
|
131 |
+
if indices_path.exists():
|
132 |
+
for item in indices_path.iterdir():
|
133 |
+
if item.is_file():
|
134 |
+
item.unlink()
|
135 |
+
elif item.is_dir():
|
136 |
+
shutil.rmtree(item)
|
137 |
+
|
138 |
+
# Копіюємо індекси
|
139 |
+
for item in Path(existing_indices).iterdir():
|
140 |
+
if item.is_file():
|
141 |
+
shutil.copy2(item, indices_path)
|
142 |
+
elif item.is_dir():
|
143 |
+
shutil.copytree(item, indices_path / item.name)
|
144 |
+
|
145 |
+
logger.info(f"Індекси успішно скопійовано в {indices_path}")
|
146 |
+
|
147 |
+
# Оновлюємо метадані
|
148 |
+
metadata = {
|
149 |
+
"session_id": session_id,
|
150 |
+
"created_at": datetime.now().isoformat(),
|
151 |
+
"data_hash": data_hash,
|
152 |
+
"rows_count": len(merged_df),
|
153 |
+
"columns_count": len(merged_df.columns),
|
154 |
+
"copied_from": str(existing_indices)
|
155 |
+
}
|
156 |
+
|
157 |
+
with open(indices_path / "metadata.json", "w", encoding="utf-8") as f:
|
158 |
+
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
159 |
+
|
160 |
+
return {
|
161 |
+
"success": True,
|
162 |
+
"indices_dir": str(indices_path),
|
163 |
+
"data_hash": data_hash,
|
164 |
+
"reused_existing": True,
|
165 |
+
"source": str(existing_indices)
|
166 |
+
}
|
167 |
+
|
168 |
+
except Exception as copy_err:
|
169 |
+
logger.error(f"Помилка при копіюванні індексів: {copy_err}")
|
170 |
+
# Продовжуємо створення нових індексів
|
171 |
+
return self._create_new_indices(indices_path, session_id, data_hash, merged_df)
|
172 |
+
|
173 |
+
def _create_new_indices(self, indices_path, session_id, data_hash, merged_df):
|
174 |
+
"""
|
175 |
+
Створення нових індексів.
|
176 |
+
Зберігає індекси у форматі, сумісному з jira_hybrid_chat.py.
|
177 |
+
"""
|
178 |
+
if not INDEXING_AVAILABLE:
|
179 |
+
return {"error": "Функціональність індексування недоступна"}
|
180 |
+
|
181 |
+
try:
|
182 |
+
logger.info(f"Створення нових індексів для сесії {session_id}")
|
183 |
+
|
184 |
+
# Імпортуємо необхідні модулі напряму
|
185 |
+
from llama_index.core import VectorStoreIndex, StorageContext, Settings
|
186 |
+
from llama_index.core.storage.docstore import SimpleDocumentStore
|
187 |
+
from llama_index.core.node_parser import TokenTextSplitter
|
188 |
+
from llama_index.retrievers.bm25 import BM25Retriever
|
189 |
+
from llama_index.vector_stores.faiss import FaissVectorStore
|
190 |
+
import faiss
|
191 |
+
|
192 |
+
# Ініціалізуємо модель ембедингів
|
193 |
+
from modules.data_management.index_utils import initialize_embedding_model
|
194 |
+
embed_model = initialize_embedding_model()
|
195 |
+
|
196 |
+
# Отримуємо розмірність ембедингів динамічно
|
197 |
+
import numpy as np
|
198 |
+
test_embedding = embed_model.get_text_embedding("Тестовий текст")
|
199 |
+
embed_dim = len(test_embedding)
|
200 |
+
logger.info(f"Розмірність ембедингів: {embed_dim}")
|
201 |
+
|
202 |
+
# Конвертуємо DataFrame в документи
|
203 |
+
documents = self._convert_dataframe_to_documents(merged_df)
|
204 |
+
|
205 |
+
# Створюємо розділювач тексту
|
206 |
+
text_splitter = TokenTextSplitter(
|
207 |
+
chunk_size=CHUNK_SIZE,
|
208 |
+
chunk_overlap=CHUNK_OVERLAP
|
209 |
+
)
|
210 |
+
|
211 |
+
# Встановлюємо формат збереження на JSON через глобальні налаштування
|
212 |
+
# Це важливо для сумісності з jira_hybrid_chat.py
|
213 |
+
Settings.persist_json_format = True
|
214 |
+
|
215 |
+
# Створюємо FAISS індекс
|
216 |
+
faiss_index = faiss.IndexFlatL2(embed_dim)
|
217 |
+
|
218 |
+
# Створюємо контекст зберігання
|
219 |
+
docstore = SimpleDocumentStore()
|
220 |
+
vector_store = FaissVectorStore(faiss_index=faiss_index)
|
221 |
+
|
222 |
+
storage_context = StorageContext.from_defaults(
|
223 |
+
docstore=docstore,
|
224 |
+
vector_store=vector_store
|
225 |
+
)
|
226 |
+
|
227 |
+
# Встановлюємо модель ембедингів у налаштуваннях
|
228 |
+
Settings.embed_model = embed_model
|
229 |
+
|
230 |
+
# Створюємо індекс
|
231 |
+
index = VectorStoreIndex.from_documents(
|
232 |
+
documents,
|
233 |
+
storage_context=storage_context,
|
234 |
+
transformations=[text_splitter]
|
235 |
+
)
|
236 |
+
|
237 |
+
# Зберігаємо індекс у форматі JSON (через глобальні налаштування)
|
238 |
+
# НЕ передаємо json_format як аргумент
|
239 |
+
index.storage_context.persist(persist_dir=str(indices_path))
|
240 |
+
|
241 |
+
# Створюємо BM25 retriever
|
242 |
+
bm25_retriever = BM25Retriever.from_defaults(
|
243 |
+
docstore=docstore,
|
244 |
+
similarity_top_k=10
|
245 |
+
)
|
246 |
+
|
247 |
+
# Зберігаємо параметри BM25
|
248 |
+
bm25_dir = indices_path / "bm25"
|
249 |
+
bm25_dir.mkdir(exist_ok=True)
|
250 |
+
|
251 |
+
with open(bm25_dir / "params.json", "w", encoding="utf-8") as f:
|
252 |
+
json.dump({"similarity_top_k": 10}, f)
|
253 |
+
|
254 |
+
# Зберігаємо метадані
|
255 |
+
metadata = {
|
256 |
+
"session_id": session_id,
|
257 |
+
"created_at": datetime.now().isoformat(),
|
258 |
+
"data_hash": data_hash,
|
259 |
+
"rows_count": len(merged_df),
|
260 |
+
"columns_count": len(merged_df.columns),
|
261 |
+
"embedding_model": embed_model.__class__.__name__,
|
262 |
+
"embedding_dim": embed_dim,
|
263 |
+
"format": "json" # Вказуємо використаний формат збереження
|
264 |
+
}
|
265 |
+
|
266 |
+
with open(indices_path / "metadata.json", "w", encoding="utf-8") as f:
|
267 |
+
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
268 |
+
|
269 |
+
with open(indices_path / "indices.valid", "w", encoding="utf-8") as f:
|
270 |
+
f.write(f"Indices created at {datetime.now().isoformat()}")
|
271 |
+
|
272 |
+
logger.info(f"Створено файл-маркер indices.valid")
|
273 |
+
|
274 |
+
return {
|
275 |
+
"success": True,
|
276 |
+
"indices_dir": str(indices_path),
|
277 |
+
"data_hash": data_hash
|
278 |
+
}
|
279 |
+
|
280 |
+
except Exception as e:
|
281 |
+
logger.error(f"Помилка при створенні нових індексів: {e}")
|
282 |
+
return {"error": f"Помилка при створенні нових індексів: {str(e)}"}
|
283 |
+
|
284 |
+
def _save_bm25_data(self, indices_path, bm25_retriever):
|
285 |
+
"""
|
286 |
+
Збереження даних для BM25 retriever.
|
287 |
+
|
288 |
+
Args:
|
289 |
+
indices_path (Path): Шлях до директорії індексів
|
290 |
+
bm25_retriever (BM25Retriever): Об'єкт BM25Retriever
|
291 |
+
|
292 |
+
Returns:
|
293 |
+
bool: True, якщо дані успішно збережені, False у випадку помилки
|
294 |
+
"""
|
295 |
+
try:
|
296 |
+
# Створюємо директорію для BM25
|
297 |
+
bm25_dir = indices_path / "bm25"
|
298 |
+
bm25_dir.mkdir(exist_ok=True)
|
299 |
+
|
300 |
+
# Зберігаємо параметри BM25
|
301 |
+
bm25_params = {
|
302 |
+
"similarity_top_k": bm25_retriever.similarity_top_k,
|
303 |
+
"alpha": getattr(bm25_retriever, "alpha", 0.75),
|
304 |
+
"beta": getattr(bm25_retriever, "beta", 0.75),
|
305 |
+
"index_creation_time": datetime.now().isoformat()
|
306 |
+
}
|
307 |
+
|
308 |
+
with open(bm25_dir / "params.json", "w", encoding="utf-8") as f:
|
309 |
+
json.dump(bm25_params, f, ensure_ascii=False, indent=2)
|
310 |
+
|
311 |
+
logger.info(f"Дані BM25 збережено в {bm25_dir}")
|
312 |
+
return True
|
313 |
+
|
314 |
+
except Exception as e:
|
315 |
+
logger.error(f"Помилка при збереженні даних BM25: {e}")
|
316 |
+
return False
|
317 |
+
|
318 |
+
def _convert_dataframe_to_documents(self, df):
|
319 |
+
"""
|
320 |
+
Конвертує DataFrame в документи для індексування.
|
321 |
+
|
322 |
+
Args:
|
323 |
+
df (pandas.DataFrame): DataFrame для конвертації
|
324 |
+
|
325 |
+
Returns:
|
326 |
+
list: Список документів
|
327 |
+
"""
|
328 |
+
try:
|
329 |
+
# Імпортуємо Document напряму
|
330 |
+
from llama_index.core import Document
|
331 |
+
|
332 |
+
documents = []
|
333 |
+
|
334 |
+
# Перебираємо рядки DataFrame
|
335 |
+
for idx, row in df.iterrows():
|
336 |
+
# Створюємо текст документа
|
337 |
+
text = f"Issue Key: {row.get('Issue key', '')}\n"
|
338 |
+
text += f"Summary: {row.get('Summary', '')}\n"
|
339 |
+
text += f"Status: {row.get('Status', '')}\n"
|
340 |
+
text += f"Issue Type: {row.get('Issue Type', '')}\n"
|
341 |
+
|
342 |
+
# Додаємо опис, якщо він є
|
343 |
+
if 'Description' in row and pd.notna(row['Description']):
|
344 |
+
text += f"Description: {row['Description']}\n"
|
345 |
+
|
346 |
+
# Додаємо коментарі, якщо вони є
|
347 |
+
if 'Comments' in row and pd.notna(row['Comments']):
|
348 |
+
text += f"Comments: {row['Comments']}\n"
|
349 |
+
|
350 |
+
# Створюємо метадані
|
351 |
+
metadata = {
|
352 |
+
"issue_key": row.get('Issue key', ''),
|
353 |
+
"summary": row.get('Summary', ''),
|
354 |
+
"status": row.get('Status', ''),
|
355 |
+
"issue_type": row.get('Issue Type', ''),
|
356 |
+
"created": str(row.get('Created', '')),
|
357 |
+
"updated": str(row.get('Updated', ''))
|
358 |
+
}
|
359 |
+
|
360 |
+
# Створюємо документ
|
361 |
+
doc = Document(
|
362 |
+
text=text,
|
363 |
+
metadata=metadata
|
364 |
+
)
|
365 |
+
|
366 |
+
documents.append(doc)
|
367 |
+
|
368 |
+
logger.info(f"Створено {len(documents)} документів з DataFrame")
|
369 |
+
return documents
|
370 |
+
|
371 |
+
except Exception as e:
|
372 |
+
logger.error(f"Помилка при конвертації DataFrame в документи: {e}")
|
373 |
+
raise
|
374 |
+
|
375 |
+
def _generate_data_hash(self, df):
|
376 |
+
"""
|
377 |
+
Генерація хешу для DataFrame для ідентифікації унікальних даних.
|
378 |
+
|
379 |
+
Args:
|
380 |
+
df (pandas.DataFrame): DataFrame для хешування
|
381 |
+
|
382 |
+
Returns:
|
383 |
+
str: Хеш даних
|
384 |
+
"""
|
385 |
+
try:
|
386 |
+
# Використовуємо основні колонки для хешування
|
387 |
+
key_columns = ['Issue key', 'Summary', 'Status', 'Issue Type', 'Created', 'Updated']
|
388 |
+
|
389 |
+
# Фільтруємо тільки наявні колонки
|
390 |
+
available_columns = [col for col in key_columns if col in df.columns]
|
391 |
+
|
392 |
+
if not available_columns:
|
393 |
+
# Якщо немає жодної ключової колонки, використовуємо всі дані
|
394 |
+
data_str = df.to_json()
|
395 |
+
else:
|
396 |
+
# Інакше використовуємо тільки ключові колонки
|
397 |
+
data_str = df[available_columns].to_json()
|
398 |
+
|
399 |
+
# Створюємо хеш
|
400 |
+
hash_object = hashlib.sha256(data_str.encode())
|
401 |
+
data_hash = hash_object.hexdigest()
|
402 |
+
|
403 |
+
return data_hash
|
404 |
+
|
405 |
+
except Exception as e:
|
406 |
+
logger.error(f"Помилка при генерації хешу даних: {e}")
|
407 |
+
# У випадку помилки повертаємо випадковий хеш
|
408 |
+
return str(uuid.uuid4())
|
409 |
+
|
410 |
+
def _find_indices_by_hash(self, data_hash):
|
411 |
+
"""
|
412 |
+
Пошук існуючих індексів за хешем даних.
|
413 |
+
|
414 |
+
Args:
|
415 |
+
data_hash (str): Хеш даних
|
416 |
+
|
417 |
+
Returns:
|
418 |
+
str: Шлях до директорії з індексами або None, якщо не знайдено
|
419 |
+
"""
|
420 |
+
try:
|
421 |
+
# Перебираємо всі піддиректорії в базовій директорії індексів
|
422 |
+
for index_dir in self.base_indices_dir.iterdir():
|
423 |
+
if not index_dir.is_dir():
|
424 |
+
continue
|
425 |
+
|
426 |
+
# Перевіряємо метадані
|
427 |
+
metadata_file = index_dir / "metadata.json"
|
428 |
+
if not metadata_file.exists():
|
429 |
+
continue
|
430 |
+
|
431 |
+
try:
|
432 |
+
with open(metadata_file, "r", encoding="utf-8") as f:
|
433 |
+
metadata = json.load(f)
|
434 |
+
|
435 |
+
# Перевіряємо хеш
|
436 |
+
if metadata.get("data_hash") == data_hash:
|
437 |
+
# Перевіряємо наявність необхідних файлів
|
438 |
+
if validate_index_directory(index_dir):
|
439 |
+
logger.info(f"Знайдено існуючі індекси з відповідним хешем: {index_dir}")
|
440 |
+
return str(index_dir)
|
441 |
+
else:
|
442 |
+
logger.warning(f"Знайдено індекси з відповідним хешем, але вони неповні: {index_dir}")
|
443 |
+
except Exception as e:
|
444 |
+
logger.error(f"Помилка при перевірці метаданих {metadata_file}: {e}")
|
445 |
+
|
446 |
+
logger.info(f"Не знайдено існуючих індексів з хешем {data_hash}")
|
447 |
+
return None
|
448 |
+
|
449 |
+
except Exception as e:
|
450 |
+
logger.error(f"Помилка при пошуку індексів за хешем: {e}")
|
451 |
+
return None
|
452 |
+
|
453 |
+
def cleanup_old_indices(self, max_age_days=7, max_indices=20):
|
454 |
+
"""
|
455 |
+
Очищення застарілих індексів.
|
456 |
+
|
457 |
+
Args:
|
458 |
+
max_age_days (int): Максимальний вік індексів у днях
|
459 |
+
max_indices (int): Максимальна кількість індексів для зберігання
|
460 |
+
|
461 |
+
Returns:
|
462 |
+
int: Кількість видалених директорій індексів
|
463 |
+
"""
|
464 |
+
try:
|
465 |
+
# Перевіряємо, чи існує базова директорія
|
466 |
+
if not self.base_indices_dir.exists():
|
467 |
+
return 0
|
468 |
+
|
469 |
+
# Отримуємо список директорій індексів
|
470 |
+
index_dirs = []
|
471 |
+
|
472 |
+
for index_dir in self.base_indices_dir.iterdir():
|
473 |
+
if not index_dir.is_dir():
|
474 |
+
continue
|
475 |
+
|
476 |
+
# Перевіряємо метадані для отримання часу створення
|
477 |
+
metadata_file = index_dir / "metadata.json"
|
478 |
+
created_at = None
|
479 |
+
|
480 |
+
if metadata_file.exists():
|
481 |
+
try:
|
482 |
+
with open(metadata_file, "r", encoding="utf-8") as f:
|
483 |
+
metadata = json.load(f)
|
484 |
+
created_at = metadata.get("created_at")
|
485 |
+
except Exception:
|
486 |
+
pass
|
487 |
+
|
488 |
+
# Якщо немає метаданих, використовуємо час створення директорії
|
489 |
+
if not created_at:
|
490 |
+
created_at = datetime.fromtimestamp(index_dir.stat().st_mtime).isoformat()
|
491 |
+
|
492 |
+
# Додаємо інформацію про директорію
|
493 |
+
index_dirs.append({
|
494 |
+
"path": str(index_dir),
|
495 |
+
"created_at": created_at
|
496 |
+
})
|
497 |
+
|
498 |
+
# Якщо немає директорій для обробки, повертаємо 0
|
499 |
+
if not index_dirs:
|
500 |
+
return 0
|
501 |
+
|
502 |
+
# Сортуємо директорії за часом створення (від найновіших до найстаріших)
|
503 |
+
index_dirs.sort(key=lambda x: x["created_at"], reverse=True)
|
504 |
+
|
505 |
+
# Визначаємо директорії для видалення
|
506 |
+
dirs_to_delete = []
|
507 |
+
|
508 |
+
# 1. Залишаємо max_indices найновіших директорій
|
509 |
+
if len(index_dirs) > max_indices:
|
510 |
+
dirs_to_delete.extend(index_dirs[max_indices:])
|
511 |
+
|
512 |
+
# 2. Перевіряємо, чи є серед залишених застарілі директорії
|
513 |
+
cutoff_date = (datetime.now() - timedelta(days=max_age_days)).isoformat()
|
514 |
+
|
515 |
+
for index_info in index_dirs[:max_indices]:
|
516 |
+
if index_info["created_at"] < cutoff_date:
|
517 |
+
dirs_to_delete.append(index_info)
|
518 |
+
|
519 |
+
# Видаляємо директорії
|
520 |
+
deleted_count = 0
|
521 |
+
|
522 |
+
for dir_info in dirs_to_delete:
|
523 |
+
try:
|
524 |
+
dir_path = Path(dir_info["path"])
|
525 |
+
if dir_path.exists():
|
526 |
+
shutil.rmtree(dir_path)
|
527 |
+
logger.info(f"Видалено застарілу директорію індексів: {dir_path}")
|
528 |
+
deleted_count += 1
|
529 |
+
except Exception as e:
|
530 |
+
logger.error(f"Помилка при видаленні директорії {dir_info['path']}: {e}")
|
531 |
+
|
532 |
+
return deleted_count
|
533 |
+
|
534 |
+
except Exception as e:
|
535 |
+
logger.error(f"Помилка при очищенні застарілих індексів: {e}")
|
536 |
+
return 0
|
537 |
+
|
538 |
+
def load_indices(self, indices_dir):
|
539 |
+
"""
|
540 |
+
Завантаження індексів з директорії.
|
541 |
+
|
542 |
+
Args:
|
543 |
+
indices_dir (str): Шлях до директорії з індексами
|
544 |
+
|
545 |
+
Returns:
|
546 |
+
tuple: (VectorStoreIndex, BM25Retriever) або (None, None) у випадку помилки
|
547 |
+
"""
|
548 |
+
if not self.indexing_available:
|
549 |
+
logger.warning("Функціональність індексування недоступна. Встановіть необхідні пакети.")
|
550 |
+
return None, None
|
551 |
+
|
552 |
+
try:
|
553 |
+
# Перевіряємо цілісність індексів
|
554 |
+
is_valid, message = check_index_integrity(indices_dir)
|
555 |
+
if not is_valid:
|
556 |
+
logger.error(f"Індекси не пройшли перевірку цілісності: {message}")
|
557 |
+
return None, None
|
558 |
+
|
559 |
+
indices_path = Path(indices_dir)
|
560 |
+
|
561 |
+
if not indices_path.exists():
|
562 |
+
logger.error(f"Директорія індексів не існує: {indices_dir}")
|
563 |
+
return None, None
|
564 |
+
|
565 |
+
# Перевіряємо наявність необхідних файлів
|
566 |
+
if not (indices_path / "docstore.json").exists():
|
567 |
+
logger.error(f"Директорія індексів не містить необхідних файлів: {indices_dir}")
|
568 |
+
return None, None
|
569 |
+
|
570 |
+
# Імпортуємо необхідні модулі
|
571 |
+
StorageContext = INDEXING_MODULES.get("StorageContext")
|
572 |
+
VectorStoreIndex = INDEXING_MODULES.get("VectorStoreIndex")
|
573 |
+
BM25Retriever = INDEXING_MODULES.get("BM25Retriever")
|
574 |
+
|
575 |
+
# Завантажуємо контекст зберігання
|
576 |
+
storage_context = StorageContext.from_defaults(persist_dir=str(indices_path))
|
577 |
+
|
578 |
+
# Завантажуємо індекс
|
579 |
+
index = VectorStoreIndex.from_storage_context(storage_context)
|
580 |
+
|
581 |
+
# Створюємо BM25 retriever
|
582 |
+
bm25_retriever = BM25Retriever.from_defaults(
|
583 |
+
docstore=storage_context.docstore,
|
584 |
+
similarity_top_k=10
|
585 |
+
)
|
586 |
+
|
587 |
+
# Завантажуємо параметри BM25, якщо вони є
|
588 |
+
bm25_params_file = indices_path / "bm25" / "params.json"
|
589 |
+
if bm25_params_file.exists():
|
590 |
+
try:
|
591 |
+
with open(bm25_params_file, "r", encoding="utf-8") as f:
|
592 |
+
bm25_params = json.load(f)
|
593 |
+
|
594 |
+
# Встановлюємо параметри
|
595 |
+
if "similarity_top_k" in bm25_params:
|
596 |
+
bm25_retriever.similarity_top_k = bm25_params["similarity_top_k"]
|
597 |
+
except Exception as e:
|
598 |
+
logger.warning(f"Помилка при завантаженні параметрів BM25: {e}")
|
599 |
+
|
600 |
+
logger.info(f"Індекси успішно завантажено з {indices_dir}")
|
601 |
+
|
602 |
+
return index, bm25_retriever
|
603 |
+
|
604 |
+
except Exception as e:
|
605 |
+
logger.error(f"Помилка при завантаженні індексів: {e}")
|
606 |
+
return None, None
|
modules/data_management/index_utils.py
ADDED
@@ -0,0 +1,457 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import logging
|
2 |
+
import os
|
3 |
+
import json
|
4 |
+
import traceback
|
5 |
+
from pathlib import Path
|
6 |
+
import pandas as pd
|
7 |
+
import tiktoken
|
8 |
+
from typing import List, Dict, Any, Optional, Tuple
|
9 |
+
|
10 |
+
from llama_index.core import (
|
11 |
+
Document,
|
12 |
+
)
|
13 |
+
|
14 |
+
|
15 |
+
# Встановлюємо змінну середовища, щоб примусово використовувати CPU
|
16 |
+
os.environ["CUDA_VISIBLE_DEVICES"] = ""
|
17 |
+
os.environ["TORCH_DEVICE"] = "cpu"
|
18 |
+
|
19 |
+
from modules.config.ai_settings import (
|
20 |
+
get_metadata_csv,
|
21 |
+
CHUNK_SIZE,
|
22 |
+
CHUNK_OVERLAP,
|
23 |
+
EXCLUDED_EMBED_METADATA_KEYS,
|
24 |
+
EXCLUDED_LLM_METADATA_KEYS,
|
25 |
+
GOOGLE_EMBEDDING_MODEL
|
26 |
+
)
|
27 |
+
|
28 |
+
# Налаштування логування
|
29 |
+
logger = logging.getLogger(__name__)
|
30 |
+
|
31 |
+
def initialize_embedding_model():
|
32 |
+
"""
|
33 |
+
Ініціалізує модель ембедингів згідно налаштувань.
|
34 |
+
Використовує офіційний пакет GeminiEmbedding для Google Embeddings.
|
35 |
+
|
36 |
+
Returns:
|
37 |
+
object: Модель ембедингів
|
38 |
+
"""
|
39 |
+
try:
|
40 |
+
# ПЕРША СПРОБА: Google Embeddings через офіційний пакет
|
41 |
+
google_api_key = os.getenv("GEMINI_API_KEY")
|
42 |
+
|
43 |
+
# Перевіряємо наявність API ключа
|
44 |
+
if google_api_key:
|
45 |
+
try:
|
46 |
+
logger.info("Спроба ініціалізації Google Embeddings API через GeminiEmbedding...")
|
47 |
+
|
48 |
+
from llama_index.embeddings.gemini import GeminiEmbedding
|
49 |
+
|
50 |
+
# Використовуємо модель Gemini для ембедингів
|
51 |
+
model_name = "models/embedding-004" # або "models/text-embedding-001"
|
52 |
+
|
53 |
+
# Створюємо модель ембедингів Gemini
|
54 |
+
embed_model = GeminiEmbedding(
|
55 |
+
model_name=model_name,
|
56 |
+
api_key=google_api_key,
|
57 |
+
task_type="retrieval_query" # або "retrieval_document"
|
58 |
+
)
|
59 |
+
|
60 |
+
# Тестуємо модель
|
61 |
+
logger.info("Виконуємо тестовий запит до Gemini Embeddings API...")
|
62 |
+
test_embedding = embed_model.get_text_embedding("Тестовий запит до Gemini Embeddings API")
|
63 |
+
|
64 |
+
if test_embedding:
|
65 |
+
logger.info(f"Тестовий запит успішний, отримано ембединг розмірністю {len(test_embedding)}")
|
66 |
+
logger.info(f"Успішно ініціалізовано модель ембедингів Google Gemini: {model_name}")
|
67 |
+
return embed_model
|
68 |
+
else:
|
69 |
+
raise Exception("Тестове підключення до Google API не вдалося - отримано порожній результат")
|
70 |
+
|
71 |
+
except ImportError as imp_err:
|
72 |
+
logger.error(f"Помилка імпорту модуля GeminiEmbedding: {imp_err}")
|
73 |
+
logger.error("Можливо, потрібно встановити пакет: pip install llama-index-embeddings-gemini")
|
74 |
+
logger.warning("Спробуємо альтернативні методи...")
|
75 |
+
|
76 |
+
# Спробуємо альтернативний імпорт для Google AI SDK
|
77 |
+
try:
|
78 |
+
# Через Google GenAI SDK безпосередньо
|
79 |
+
from google import genai
|
80 |
+
|
81 |
+
logger.info("Спроба ініціалізації через Google GenAI API безпосередньо...")
|
82 |
+
|
83 |
+
# Ініціалізуємо клієнт Google GenAI
|
84 |
+
genai.configure(api_key=google_api_key)
|
85 |
+
client = genai.Client()
|
86 |
+
|
87 |
+
# Функція для отримання ембедингів від Google API
|
88 |
+
def get_google_embeddings(texts):
|
89 |
+
if not isinstance(texts, list):
|
90 |
+
texts = [texts]
|
91 |
+
|
92 |
+
try:
|
93 |
+
# Використовуємо Google Embeddings API
|
94 |
+
result = client.models.embed_content(
|
95 |
+
model=GOOGLE_EMBEDDING_MODEL,
|
96 |
+
contents=texts,
|
97 |
+
config={"task_type": "retrieval_query"}
|
98 |
+
)
|
99 |
+
|
100 |
+
# Виймаємо ембединги
|
101 |
+
embeddings = [embedding.values for embedding in result.embeddings]
|
102 |
+
|
103 |
+
# Повертаємо в правильному форматі для LlamaIndex
|
104 |
+
return embeddings[0] if len(embeddings) == 1 else embeddings
|
105 |
+
except Exception as e:
|
106 |
+
logger.error(f"Помилка при отриманні ембедингів від Google API: {e}")
|
107 |
+
logger.error(traceback.format_exc())
|
108 |
+
raise
|
109 |
+
|
110 |
+
# Тестуємо
|
111 |
+
test_result = get_google_embeddings(["Тестовий запит до Google GenAI API"])
|
112 |
+
if test_result:
|
113 |
+
# Створюємо кастомну модель ембедингів
|
114 |
+
embed_model = CustomEmbedding(
|
115 |
+
embed_func=get_google_embeddings,
|
116 |
+
embed_batch_size=8
|
117 |
+
)
|
118 |
+
|
119 |
+
logger.info(f"Успішно ініціалізовано кастомну модель ембедингів Google через GenAI SDK")
|
120 |
+
return embed_model
|
121 |
+
else:
|
122 |
+
raise Exception("Тестове підключення до Google API не вдалося")
|
123 |
+
except ImportError:
|
124 |
+
logger.error("Не вдалося імпортувати ні llama-index-embeddings-gemini, ні google.genai")
|
125 |
+
except Exception as e:
|
126 |
+
logger.error(f"Помилка при альтернативній ініціалізації Google Embeddings: {e}")
|
127 |
+
logger.error(traceback.format_exc())
|
128 |
+
|
129 |
+
except Exception as e:
|
130 |
+
logger.error(f"Не вдалося ініціалізувати Google Embeddings API: {e}")
|
131 |
+
logger.error(traceback.format_exc())
|
132 |
+
|
133 |
+
else:
|
134 |
+
logger.warning("API ключ Google не знайдено (змінна GOOGLE_API_KEY не встановлена)")
|
135 |
+
logger.warning("Будь ласка, додайте GOOGLE_API_KEY у файл .env або змінні середовища")
|
136 |
+
|
137 |
+
# ДРУГА СПРОБА: HuggingFace ембединги
|
138 |
+
logger.info("Використання локальних HuggingFace ембедингів...")
|
139 |
+
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
|
140 |
+
from modules.config.ai_settings import DEFAULT_EMBEDDING_MODEL, FALLBACK_EMBEDDING_MODEL
|
141 |
+
|
142 |
+
try:
|
143 |
+
# Явно вказуємо використання CPU
|
144 |
+
embed_model = HuggingFaceEmbedding(
|
145 |
+
model_name=DEFAULT_EMBEDDING_MODEL,
|
146 |
+
device="cpu" # Явно вказуємо CPU
|
147 |
+
)
|
148 |
+
logger.info(f"Успішно ініціалізовано модель ембедингів HuggingFace на CPU: {DEFAULT_EMBEDDING_MODEL}")
|
149 |
+
return embed_model
|
150 |
+
except Exception as e:
|
151 |
+
logger.warning(f"Не вдалося ініціалізувати основну модель HuggingFace ембедингів: {e}")
|
152 |
+
|
153 |
+
# Спробуємо резервну модель
|
154 |
+
try:
|
155 |
+
embed_model = HuggingFaceEmbedding(
|
156 |
+
model_name=FALLBACK_EMBEDDING_MODEL,
|
157 |
+
device="cpu" # Явно вказуємо CPU
|
158 |
+
)
|
159 |
+
logger.info(f"Успішно ініціалізовано резервну модель HuggingFace ембедингів на CPU: {FALLBACK_EMBEDDING_MODEL}")
|
160 |
+
return embed_model
|
161 |
+
except Exception as fallback_error:
|
162 |
+
logger.error(f"Не вдалося ініціалізувати резервну модель HuggingFace: {fallback_error}")
|
163 |
+
|
164 |
+
# Створення найпростішого фальшивого ембедера для аварійної ситуації
|
165 |
+
try:
|
166 |
+
from llama_index.embeddings.custom import CustomEmbedding
|
167 |
+
except ImportError:
|
168 |
+
# Для сумісності зі старими версіями бібліотеки
|
169 |
+
from llama_index.core.embeddings.custom import CustomEmbedding
|
170 |
+
|
171 |
+
import numpy as np
|
172 |
+
|
173 |
+
def fallback_embedding_func(texts):
|
174 |
+
if not isinstance(texts, list):
|
175 |
+
texts = [texts]
|
176 |
+
|
177 |
+
# Генеруємо фіктивні ембедінги (розмірність 768 - типова)
|
178 |
+
embeddings = [np.random.rand(768).tolist() for _ in texts]
|
179 |
+
return embeddings[0] if len(embeddings) == 1 else embeddings
|
180 |
+
|
181 |
+
logger.warning("Використовуємо аварійний фальшивий ембедер")
|
182 |
+
return CustomEmbedding(embed_func=fallback_embedding_func)
|
183 |
+
|
184 |
+
except Exception as e:
|
185 |
+
logger.error(f"Критична помилка при ініціалізації моделей ембедингів: {e}")
|
186 |
+
logger.error(traceback.format_exc())
|
187 |
+
|
188 |
+
# Аварійний фальшивий ембедер
|
189 |
+
try:
|
190 |
+
from llama_index.embeddings.custom import CustomEmbedding
|
191 |
+
except ImportError:
|
192 |
+
# Для сумісності зі старими версіями бібліотеки
|
193 |
+
from llama_index.core.embeddings.custom import CustomEmbedding
|
194 |
+
|
195 |
+
import numpy as np
|
196 |
+
|
197 |
+
def emergency_embedding_func(texts):
|
198 |
+
if not isinstance(texts, list):
|
199 |
+
texts = [texts]
|
200 |
+
return [np.random.rand(768).tolist() for _ in texts]
|
201 |
+
|
202 |
+
logger.warning("Використовуємо аварійний фальшивий ембедер через критичну помилку")
|
203 |
+
return CustomEmbedding(embed_func=emergency_embedding_func)
|
204 |
+
|
205 |
+
|
206 |
+
def count_tokens(text, model="gpt-3.5-turbo"):
|
207 |
+
"""
|
208 |
+
Підраховує приблизну кількість токенів для тексту.
|
209 |
+
|
210 |
+
Args:
|
211 |
+
text (str): Текст для підрахунку токенів
|
212 |
+
model (str): Назва моделі для вибору енкодера
|
213 |
+
|
214 |
+
Returns:
|
215 |
+
int: Кількість токенів
|
216 |
+
"""
|
217 |
+
try:
|
218 |
+
encoding = tiktoken.encoding_for_model(model)
|
219 |
+
tokens = encoding.encode(text)
|
220 |
+
return len(tokens)
|
221 |
+
except Exception as e:
|
222 |
+
logger.warning(f"Не вдалося підрахувати токени через tiktoken: {e}")
|
223 |
+
# Якщо не можемо використати tiktoken, робимо просту оцінку
|
224 |
+
return len(text) // 3 # Приблизна оцінка
|
225 |
+
|
226 |
+
def convert_dataframe_to_documents(df: pd.DataFrame) -> List[Document]:
|
227 |
+
"""
|
228 |
+
Перетворює DataFrame з даними Jira в документи для індексування.
|
229 |
+
|
230 |
+
Args:
|
231 |
+
df (pd.DataFrame): DataFrame з даними Jira
|
232 |
+
|
233 |
+
Returns:
|
234 |
+
List[Document]: Список документів для індексування
|
235 |
+
"""
|
236 |
+
logger.info("Перетворення даних DataFrame в документи для LlamaIndex...")
|
237 |
+
|
238 |
+
jira_documents = []
|
239 |
+
total_tokens = 0
|
240 |
+
|
241 |
+
for idx, row in df.iterrows():
|
242 |
+
# Основний текст - опис тікета
|
243 |
+
text = ""
|
244 |
+
if 'Description' in row and pd.notnull(row['Description']):
|
245 |
+
text = str(row['Description'])
|
246 |
+
|
247 |
+
# Додавання коментарів, якщо вони є
|
248 |
+
for col in df.columns:
|
249 |
+
if col.startswith('Comment') and pd.notnull(row[col]):
|
250 |
+
text += f"\n\nКоментар: {str(row[col])}"
|
251 |
+
|
252 |
+
# Метадані для документа
|
253 |
+
metadata = metadata = get_metadata_csv(row, idx)
|
254 |
+
|
255 |
+
# Додатково перевіряємо поле зв'язків, якщо воно є
|
256 |
+
if 'Outward issue link (Relates)' in row and pd.notnull(row['Outward issue link (Relates)']):
|
257 |
+
metadata["related_issues"] = row['Outward issue link (Relates)']
|
258 |
+
|
259 |
+
# Додатково перевіряємо інші можливі поля зв'язків
|
260 |
+
for col in df.columns:
|
261 |
+
if col.startswith('Outward issue link') and col != 'Outward issue link (Relates)' and pd.notnull(row[col]):
|
262 |
+
link_type = col.replace('Outward issue link (', '').replace(')', '')
|
263 |
+
if "links" not in metadata:
|
264 |
+
metadata["links"] = {}
|
265 |
+
metadata["links"][link_type] = str(row[col])
|
266 |
+
|
267 |
+
# Створюємо документ з вказаними виключеннями
|
268 |
+
doc = Document(
|
269 |
+
text=text,
|
270 |
+
metadata=metadata,
|
271 |
+
excluded_embed_metadata_keys=EXCLUDED_EMBED_METADATA_KEYS,
|
272 |
+
excluded_llm_metadata_keys=EXCLUDED_LLM_METADATA_KEYS
|
273 |
+
)
|
274 |
+
|
275 |
+
# Підраховуємо токени
|
276 |
+
token_count = count_tokens(text)
|
277 |
+
total_tokens += token_count
|
278 |
+
|
279 |
+
# Додаємо документ до списку
|
280 |
+
jira_documents.append(doc)
|
281 |
+
|
282 |
+
logger.info(f"Створено {len(jira_documents)} документів з {total_tokens} токенами")
|
283 |
+
return jira_documents
|
284 |
+
|
285 |
+
def check_index_integrity(indices_path: str) -> Tuple[bool, str]:
|
286 |
+
"""
|
287 |
+
Перевіряє цілісність індексів.
|
288 |
+
|
289 |
+
Args:
|
290 |
+
indices_path (str): Шлях до директорії з індексами
|
291 |
+
|
292 |
+
Returns:
|
293 |
+
Tuple[bool, str]: (True, '') якщо індекси валідні, (False, 'повідомлення про помилку') в іншому випадку
|
294 |
+
"""
|
295 |
+
try:
|
296 |
+
indices_path = Path(indices_path)
|
297 |
+
|
298 |
+
# Перевірка наявності директо��ії
|
299 |
+
if not indices_path.exists() or not indices_path.is_dir():
|
300 |
+
return False, f"Директорія з індексами не існує: {indices_path}"
|
301 |
+
|
302 |
+
# Перевірка наявності маркера валідності
|
303 |
+
valid_marker = indices_path / "indices.valid"
|
304 |
+
if not valid_marker.exists():
|
305 |
+
return False, f"Маркер валідності індексів не знайдено в {indices_path}"
|
306 |
+
|
307 |
+
# Перевірка наявності файлів індексів
|
308 |
+
required_files = ["docstore.json"]
|
309 |
+
for file in required_files:
|
310 |
+
if not (indices_path / file).exists():
|
311 |
+
return False, f"Файл {file} не знайдено в {indices_path}"
|
312 |
+
|
313 |
+
# Перевірка наявності BM25 індексу
|
314 |
+
bm25_path = indices_path / "bm25"
|
315 |
+
if not bm25_path.exists() or not bm25_path.is_dir():
|
316 |
+
return False, f"Директорія з BM25 індексом не знайдено в {indices_path}"
|
317 |
+
|
318 |
+
return True, ""
|
319 |
+
|
320 |
+
except Exception as e:
|
321 |
+
return False, f"Помилка при перевірці цілісності індексів: {str(e)}"
|
322 |
+
|
323 |
+
def check_indexing_availability(indices_path=None):
|
324 |
+
"""
|
325 |
+
Перевіряє доступність функціональності індексування.
|
326 |
+
|
327 |
+
Returns:
|
328 |
+
bool: True, якщо функціональність доступна, False - інакше
|
329 |
+
"""
|
330 |
+
try:
|
331 |
+
# Перевіряємо наявність необхідних модулів
|
332 |
+
import importlib
|
333 |
+
|
334 |
+
# Список необхідних модулів
|
335 |
+
required_modules = [
|
336 |
+
"llama_index.core",
|
337 |
+
"llama_index.retrievers.bm25",
|
338 |
+
"llama_index.vector_stores.faiss",
|
339 |
+
"llama_index.embeddings.huggingface"
|
340 |
+
]
|
341 |
+
|
342 |
+
# Додаємо Google Embeddings до списку, якщо встановлено змінну середовища
|
343 |
+
if os.getenv("GEMINI_API_KEY"):
|
344 |
+
required_modules.append("google.genai")
|
345 |
+
|
346 |
+
# Перевіряємо кожен модуль
|
347 |
+
for module_name in required_modules:
|
348 |
+
try:
|
349 |
+
importlib.import_module(module_name)
|
350 |
+
except ImportError:
|
351 |
+
logger.warning(f"Модуль {module_name} не знайдено")
|
352 |
+
return False
|
353 |
+
|
354 |
+
# Всі модулі доступні
|
355 |
+
logger.info("Всі необхідні модулі для індексування доступні")
|
356 |
+
return True
|
357 |
+
|
358 |
+
except Exception as e:
|
359 |
+
logger.error(f"Помилка при перевірці доступності індексування: {e}")
|
360 |
+
return False
|
361 |
+
|
362 |
+
def validate_index_directory(indices_path):
|
363 |
+
"""
|
364 |
+
Перевіряє, чи директорія з індексами існує та містить необхідні файли.
|
365 |
+
|
366 |
+
Args:
|
367 |
+
indices_path (str): Шлях до директорії з індексами
|
368 |
+
|
369 |
+
Returns:
|
370 |
+
bool: True, якщо директорія валідна, False - інакше
|
371 |
+
"""
|
372 |
+
try:
|
373 |
+
from pathlib import Path
|
374 |
+
|
375 |
+
indices_path = Path(indices_path)
|
376 |
+
|
377 |
+
# Перевірка наявності директорії
|
378 |
+
if not indices_path.exists() or not indices_path.is_dir():
|
379 |
+
return False
|
380 |
+
|
381 |
+
# Перевірка наявності необхідних файлів
|
382 |
+
required_files = ["docstore.json"]
|
383 |
+
for file in required_files:
|
384 |
+
if not (indices_path / file).exists():
|
385 |
+
return False
|
386 |
+
|
387 |
+
return True
|
388 |
+
|
389 |
+
except Exception as e:
|
390 |
+
logger.error(f"Помилка при валідації директорії індексів: {str(e)}")
|
391 |
+
return False
|
392 |
+
|
393 |
+
def test_google_embeddings():
|
394 |
+
"""
|
395 |
+
Функція для тестування та відлагодження Google Embeddings API.
|
396 |
+
Можна запустити як окремий скрипт для перевірки роботи API.
|
397 |
+
|
398 |
+
Запуск з командного рядка:
|
399 |
+
python -c "from modules.data_management.index_utils import test_google_embeddings; test_google_embeddings()"
|
400 |
+
"""
|
401 |
+
import os
|
402 |
+
import logging
|
403 |
+
|
404 |
+
# Налаштування логування
|
405 |
+
logging.basicConfig(level=logging.INFO)
|
406 |
+
logger = logging.getLogger(__name__)
|
407 |
+
|
408 |
+
logger.info("Тестування Google Embeddings API...")
|
409 |
+
|
410 |
+
# Отримання API ключа
|
411 |
+
api_key = os.getenv("GEMINI_API_KEY")
|
412 |
+
if not api_key:
|
413 |
+
logger.error("GEMINI_API_KEY не знайдений. Перевірте ваш .env файл або змінні середовища.")
|
414 |
+
return False
|
415 |
+
|
416 |
+
logger.info(f"API ключ Google знайде��о: {api_key[:5]}...{api_key[-5:] if len(api_key) > 10 else '***'}")
|
417 |
+
|
418 |
+
try:
|
419 |
+
from google import genai
|
420 |
+
|
421 |
+
# Ініціалізація клієнта
|
422 |
+
genai.configure(api_key=api_key)
|
423 |
+
client = genai.Client()
|
424 |
+
logger.info("Google GenAI клієнт успішно ініціалізовано")
|
425 |
+
|
426 |
+
# Спроба отримати ембединги
|
427 |
+
text = ["Тестовий текст українською мовою"]
|
428 |
+
model = "text-embedding-004"
|
429 |
+
|
430 |
+
logger.info(f"Запит до моделі {model} з текстом: {text}")
|
431 |
+
|
432 |
+
result = client.models.embed_content(
|
433 |
+
model=model,
|
434 |
+
contents=text,
|
435 |
+
config={"task_type": "retrieval_query"}
|
436 |
+
)
|
437 |
+
|
438 |
+
# Отримання ембедингів
|
439 |
+
[embedding] = result.embeddings
|
440 |
+
embedding_values = embedding.values
|
441 |
+
|
442 |
+
logger.info(f"Ембединг успішно отримано, розмірність: {len(embedding_values)}")
|
443 |
+
logger.info(f"Перші 5 значень: {embedding_values[:5]}")
|
444 |
+
|
445 |
+
return True
|
446 |
+
|
447 |
+
except ImportError:
|
448 |
+
logger.error("Модуль google.genai не знайдено. Будь ласка, встановіть його: pip install google-genai")
|
449 |
+
return False
|
450 |
+
except Exception as e:
|
451 |
+
import traceback
|
452 |
+
logger.error(f"Помилка при тестуванні Google Embeddings API: {e}")
|
453 |
+
logger.error(traceback.format_exc())
|
454 |
+
return False
|
455 |
+
|
456 |
+
if __name__ == "__main__":
|
457 |
+
test_google_embeddings()
|
modules/data_management/session_manager.py
ADDED
@@ -0,0 +1,463 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import hashlib
|
3 |
+
import uuid
|
4 |
+
import json
|
5 |
+
import logging
|
6 |
+
import shutil
|
7 |
+
from pathlib import Path
|
8 |
+
from datetime import datetime, timedelta
|
9 |
+
import pandas as pd
|
10 |
+
|
11 |
+
logger = logging.getLogger(__name__)
|
12 |
+
|
13 |
+
class SessionManager:
|
14 |
+
"""
|
15 |
+
Менеджер сесій користувачів для управління даними в багатокористувацькому середовищі.
|
16 |
+
Забезпечує ізоляцію даних між користувачами та уникнення конфліктів.
|
17 |
+
"""
|
18 |
+
def __init__(self, base_dir="temp/sessions"):
|
19 |
+
"""
|
20 |
+
Ініціалізація менеджера сесій.
|
21 |
+
|
22 |
+
Args:
|
23 |
+
base_dir (str): Базова директорія для зберігання сесій
|
24 |
+
"""
|
25 |
+
self.base_dir = Path(base_dir)
|
26 |
+
self.base_dir.mkdir(exist_ok=True, parents=True)
|
27 |
+
|
28 |
+
# Очищення застарілих сесій при ініціалізації
|
29 |
+
self.cleanup_old_sessions()
|
30 |
+
|
31 |
+
def create_session(self, user_id=None):
|
32 |
+
"""
|
33 |
+
Створення нової сесії користувача.
|
34 |
+
|
35 |
+
Args:
|
36 |
+
user_id (str, optional): Ідентифікатор користувача. Якщо None, генерується випадковий.
|
37 |
+
|
38 |
+
Returns:
|
39 |
+
str: Ідентифікатор сесії
|
40 |
+
"""
|
41 |
+
# Якщо user_id не вказано, генеруємо випадковий
|
42 |
+
if not user_id:
|
43 |
+
user_id = str(uuid.uuid4())
|
44 |
+
|
45 |
+
# Генеруємо унікальний ідентифікатор сесії
|
46 |
+
session_id = f"{user_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
|
47 |
+
|
48 |
+
# Створюємо директорію для сесії
|
49 |
+
session_dir = self.base_dir / session_id
|
50 |
+
session_dir.mkdir(exist_ok=True)
|
51 |
+
|
52 |
+
# Створюємо підпапки для різних типів даних
|
53 |
+
(session_dir / "data").mkdir(exist_ok=True) # Для CSV та DataFrame
|
54 |
+
(session_dir / "indices").mkdir(exist_ok=True) # Для індексів FAISS та BM25
|
55 |
+
(session_dir / "reports").mkdir(exist_ok=True) # Для звітів
|
56 |
+
(session_dir / "viz").mkdir(exist_ok=True) # Для візуалізацій
|
57 |
+
|
58 |
+
# Зберігаємо метадані сесії
|
59 |
+
metadata = {
|
60 |
+
"user_id": user_id,
|
61 |
+
"created_at": datetime.now().isoformat(),
|
62 |
+
"last_accessed": datetime.now().isoformat(),
|
63 |
+
"status": "active",
|
64 |
+
"data_files": []
|
65 |
+
}
|
66 |
+
|
67 |
+
self._save_session_metadata(session_id, metadata)
|
68 |
+
|
69 |
+
logger.info(f"Створено нову сесію: {session_id}")
|
70 |
+
return session_id
|
71 |
+
|
72 |
+
def get_session_dir(self, session_id):
|
73 |
+
"""
|
74 |
+
Отримання шляху до директорії сесії.
|
75 |
+
|
76 |
+
Args:
|
77 |
+
session_id (str): Ідентифікатор сесії
|
78 |
+
|
79 |
+
Returns:
|
80 |
+
Path: Шлях до директорії сесії або None, якщо сесія не існує
|
81 |
+
"""
|
82 |
+
session_dir = self.base_dir / session_id
|
83 |
+
|
84 |
+
if not session_dir.exists():
|
85 |
+
logger.warning(f"Сесія не знайдена: {session_id}")
|
86 |
+
return None
|
87 |
+
|
88 |
+
# Оновлюємо час останнього доступу
|
89 |
+
self._update_session_access_time(session_id)
|
90 |
+
|
91 |
+
return session_dir
|
92 |
+
|
93 |
+
def get_session_data_dir(self, session_id):
|
94 |
+
"""
|
95 |
+
Отримання шляху до директорії даних сесії.
|
96 |
+
|
97 |
+
Args:
|
98 |
+
session_id (str): Ідентифікатор сесії
|
99 |
+
|
100 |
+
Returns:
|
101 |
+
Path: Шлях до директорії даних або None, якщо сесія не існує
|
102 |
+
"""
|
103 |
+
session_dir = self.get_session_dir(session_id)
|
104 |
+
if not session_dir:
|
105 |
+
return None
|
106 |
+
|
107 |
+
return session_dir / "data"
|
108 |
+
|
109 |
+
def get_session_indices_dir(self, session_id):
|
110 |
+
"""
|
111 |
+
Отримання шляху до директорії індексів сесії.
|
112 |
+
|
113 |
+
Args:
|
114 |
+
session_id (str): Ідентифікатор сесії
|
115 |
+
|
116 |
+
Returns:
|
117 |
+
Path: Шлях до директорії індексів або None, якщо сесія не існує
|
118 |
+
"""
|
119 |
+
session_dir = self.get_session_dir(session_id)
|
120 |
+
if not session_dir:
|
121 |
+
return None
|
122 |
+
|
123 |
+
return session_dir / "indices"
|
124 |
+
|
125 |
+
def add_data_file(self, session_id, file_path, file_type="uploaded", description=None):
|
126 |
+
"""
|
127 |
+
Додавання інформації про файл даних до сесії.
|
128 |
+
|
129 |
+
Args:
|
130 |
+
session_id (str): Ідентифікатор сесії
|
131 |
+
file_path (str): Шлях до файлу
|
132 |
+
file_type (str): Тип файлу ("uploaded", "local", "merged")
|
133 |
+
description (str, optional): Опис файлу
|
134 |
+
|
135 |
+
Returns:
|
136 |
+
bool: True, якщо дані успішно додані, False у випадку помилки
|
137 |
+
"""
|
138 |
+
session_dir = self.get_session_dir(session_id)
|
139 |
+
if not session_dir:
|
140 |
+
return False
|
141 |
+
|
142 |
+
# Отримуємо поточні метадані сесії
|
143 |
+
metadata = self._get_session_metadata(session_id)
|
144 |
+
if not metadata:
|
145 |
+
return False
|
146 |
+
|
147 |
+
# Генеруємо хеш файлу для відстеження дублікатів
|
148 |
+
file_hash = self._generate_file_hash(file_path)
|
149 |
+
|
150 |
+
# Додаємо інформацію про файл
|
151 |
+
file_info = {
|
152 |
+
"path": str(file_path),
|
153 |
+
"filename": os.path.basename(file_path),
|
154 |
+
"type": file_type,
|
155 |
+
"hash": file_hash,
|
156 |
+
"size": os.path.getsize(file_path) if os.path.exists(file_path) else 0,
|
157 |
+
"added_at": datetime.now().isoformat(),
|
158 |
+
"description": description or ""
|
159 |
+
}
|
160 |
+
|
161 |
+
# Перевіряємо на дублікати
|
162 |
+
for existing_file in metadata.get("data_files", []):
|
163 |
+
if existing_file.get("hash") == file_hash:
|
164 |
+
logger.warning(f"Файл вже існує в сесії: {file_path}")
|
165 |
+
return True
|
166 |
+
|
167 |
+
# Додаємо файл до списку
|
168 |
+
metadata.setdefault("data_files", []).append(file_info)
|
169 |
+
|
170 |
+
# Оновлюємо метадані
|
171 |
+
self._save_session_metadata(session_id, metadata)
|
172 |
+
|
173 |
+
logger.info(f"Додано файл даних до сесії {session_id}: {file_path}")
|
174 |
+
return True
|
175 |
+
|
176 |
+
def remove_data_file(self, session_id, file_path_or_hash):
|
177 |
+
"""
|
178 |
+
Видалення інформації про файл даних із сесії.
|
179 |
+
|
180 |
+
Args:
|
181 |
+
session_id (str): Ідентифікатор сесії
|
182 |
+
file_path_or_hash (str): Шлях до файлу або його хеш
|
183 |
+
|
184 |
+
Returns:
|
185 |
+
bool: True, якщо дані успішно видалені, False у випадку помилки
|
186 |
+
"""
|
187 |
+
session_dir = self.get_session_dir(session_id)
|
188 |
+
if not session_dir:
|
189 |
+
return False
|
190 |
+
|
191 |
+
# Отримуємо поточні метадані сесії
|
192 |
+
metadata = self._get_session_metadata(session_id)
|
193 |
+
if not metadata:
|
194 |
+
return False
|
195 |
+
|
196 |
+
# Шукаємо файл за шляхом або хешем
|
197 |
+
file_found = False
|
198 |
+
updated_files = []
|
199 |
+
|
200 |
+
for file_info in metadata.get("data_files", []):
|
201 |
+
if file_info.get("path") == file_path_or_hash or file_info.get("hash") == file_path_or_hash:
|
202 |
+
file_found = True
|
203 |
+
# Файл може бути фізично видалений, якщо він знаходиться в директорії сесії
|
204 |
+
if file_info.get("path").startswith(str(session_dir)):
|
205 |
+
try:
|
206 |
+
os.remove(file_info.get("path"))
|
207 |
+
logger.info(f"Фізично видалено файл: {file_info.get('path')}")
|
208 |
+
except Exception as e:
|
209 |
+
logger.warning(f"Не вдалося видалити файл {file_info.get('path')}: {e}")
|
210 |
+
else:
|
211 |
+
updated_files.append(file_info)
|
212 |
+
|
213 |
+
if not file_found:
|
214 |
+
logger.warning(f"Файл не знайдено в сесії: {file_path_or_hash}")
|
215 |
+
return False
|
216 |
+
|
217 |
+
# Оновлюємо список файлів
|
218 |
+
metadata["data_files"] = updated_files
|
219 |
+
|
220 |
+
# Оновлюємо метадані
|
221 |
+
self._save_session_metadata(session_id, metadata)
|
222 |
+
|
223 |
+
logger.info(f"Видалено файл з сесії {session_id}: {file_path_or_hash}")
|
224 |
+
return True
|
225 |
+
|
226 |
+
def get_session_files(self, session_id):
|
227 |
+
"""
|
228 |
+
Отримання списку файлів даних сесії.
|
229 |
+
|
230 |
+
Args:
|
231 |
+
session_id (str): Ідентифікатор сесії
|
232 |
+
|
233 |
+
Returns:
|
234 |
+
list: Список інформації про файли або порожній список у випадку помилки
|
235 |
+
"""
|
236 |
+
# Отримуємо поточні метадані сесії
|
237 |
+
metadata = self._get_session_metadata(session_id)
|
238 |
+
if not metadata:
|
239 |
+
return []
|
240 |
+
|
241 |
+
return metadata.get("data_files", [])
|
242 |
+
|
243 |
+
def save_merged_data(self, session_id, merged_df, output_filename=None):
|
244 |
+
"""
|
245 |
+
З��ереження об'єднаних даних у сесію.
|
246 |
+
|
247 |
+
Args:
|
248 |
+
session_id (str): Ідентифікатор сесії
|
249 |
+
merged_df (pandas.DataFrame): DataFrame з об'єднаними даними
|
250 |
+
output_filename (str, optional): Ім'я файлу для збереження. Якщо None, генерується автоматично.
|
251 |
+
|
252 |
+
Returns:
|
253 |
+
str: Шлях до збереженого файлу або None у випадку помилки
|
254 |
+
"""
|
255 |
+
session_data_dir = self.get_session_data_dir(session_id)
|
256 |
+
if not session_data_dir:
|
257 |
+
return None
|
258 |
+
|
259 |
+
try:
|
260 |
+
# Генеруємо ім'я файлу, якщо не вказано
|
261 |
+
if not output_filename:
|
262 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
263 |
+
output_filename = f"merged_data_{timestamp}.csv"
|
264 |
+
|
265 |
+
# Переконуємося, що файл має розширення .csv
|
266 |
+
if not output_filename.lower().endswith(".csv"):
|
267 |
+
output_filename += ".csv"
|
268 |
+
|
269 |
+
# Шлях для збереження
|
270 |
+
output_path = session_data_dir / output_filename
|
271 |
+
|
272 |
+
# Зберігаємо DataFrame у CSV
|
273 |
+
merged_df.to_csv(output_path, index=False)
|
274 |
+
|
275 |
+
# Додаємо інформацію про файл до сесії
|
276 |
+
self.add_data_file(
|
277 |
+
session_id,
|
278 |
+
str(output_path),
|
279 |
+
file_type="merged",
|
280 |
+
description="Об'єднані дані"
|
281 |
+
)
|
282 |
+
|
283 |
+
logger.info(f"Збережено об'єднані дані у сесії {session_id}: {output_path}")
|
284 |
+
return str(output_path)
|
285 |
+
|
286 |
+
except Exception as e:
|
287 |
+
logger.error(f"Помилка при збереженні об'єднаних даних: {e}")
|
288 |
+
return None
|
289 |
+
|
290 |
+
def cleanup_session(self, session_id):
|
291 |
+
"""
|
292 |
+
Очищення сесії (видалення всіх файлів і директорій).
|
293 |
+
|
294 |
+
Args:
|
295 |
+
session_id (str): Ідентифікатор сесії
|
296 |
+
|
297 |
+
Returns:
|
298 |
+
bool: True, якщо сесія успішно очищена, False у випадку помилки
|
299 |
+
"""
|
300 |
+
session_dir = self.base_dir / session_id
|
301 |
+
|
302 |
+
if not session_dir.exists():
|
303 |
+
logger.warning(f"Сесія не знайдена: {session_id}")
|
304 |
+
return False
|
305 |
+
|
306 |
+
try:
|
307 |
+
# Видаляємо всю директорію сесії
|
308 |
+
shutil.rmtree(session_dir)
|
309 |
+
logger.info(f"Сесію {session_id} успішно очищено")
|
310 |
+
return True
|
311 |
+
except Exception as e:
|
312 |
+
logger.error(f"Помилка при очищенні сесії {session_id}: {e}")
|
313 |
+
return False
|
314 |
+
|
315 |
+
def cleanup_old_sessions(self, max_age_hours=24):
|
316 |
+
"""
|
317 |
+
Очищення застарілих сесій.
|
318 |
+
|
319 |
+
Args:
|
320 |
+
max_age_hours (int): Максимальний вік сесії в годинах для збереження
|
321 |
+
|
322 |
+
Returns:
|
323 |
+
int: Кількість видалених сесій
|
324 |
+
"""
|
325 |
+
cutoff_time = datetime.now() - timedelta(hours=max_age_hours)
|
326 |
+
deleted_count = 0
|
327 |
+
|
328 |
+
# Перебираємо всі підпапки в базовій директорії
|
329 |
+
for session_dir in self.base_dir.iterdir():
|
330 |
+
if not session_dir.is_dir():
|
331 |
+
continue
|
332 |
+
|
333 |
+
# Перевіряємо час останнього доступу до сесії
|
334 |
+
metadata_file = session_dir / "metadata.json"
|
335 |
+
if not metadata_file.exists():
|
336 |
+
# Якщо немає метаданих, видаляємо директорію
|
337 |
+
try:
|
338 |
+
shutil.rmtree(session_dir)
|
339 |
+
deleted_count += 1
|
340 |
+
logger.info(f"Видалено сесію без метаданих: {session_dir.name}")
|
341 |
+
except Exception as e:
|
342 |
+
logger.error(f"Помилка при видаленні сесії {session_dir.name}: {e}")
|
343 |
+
continue
|
344 |
+
|
345 |
+
try:
|
346 |
+
with open(metadata_file, "r", encoding="utf-8") as f:
|
347 |
+
metadata = json.load(f)
|
348 |
+
|
349 |
+
last_accessed = datetime.fromisoformat(metadata.get("last_accessed", metadata.get("created_at")))
|
350 |
+
|
351 |
+
if last_accessed < cutoff_time:
|
352 |
+
# Сесія застаріла, видаляємо її
|
353 |
+
shutil.rmtree(session_dir)
|
354 |
+
deleted_count += 1
|
355 |
+
logger.info(f"Видалено застарілу сесію: {session_dir.name}, "
|
356 |
+
f"останній доступ: {last_accessed.isoformat()}")
|
357 |
+
except Exception as e:
|
358 |
+
logger.error(f"Помилка при перевірці сесії {session_dir.name}: {e}")
|
359 |
+
|
360 |
+
logger.info(f"Очищено {deleted_count} застарілих сесій")
|
361 |
+
return deleted_count
|
362 |
+
|
363 |
+
def _save_session_metadata(self, session_id, metadata):
|
364 |
+
"""
|
365 |
+
Збереження метаданих сесії.
|
366 |
+
|
367 |
+
Args:
|
368 |
+
session_id (str): Ідентифікатор сесії
|
369 |
+
metadata (dict): Метадані для збереження
|
370 |
+
|
371 |
+
Returns:
|
372 |
+
bool: True, якщо метадані успішно збережені, False у випадку помилки
|
373 |
+
"""
|
374 |
+
session_dir = self.base_dir / session_id
|
375 |
+
|
376 |
+
if not session_dir.exists():
|
377 |
+
logger.warning(f"Сесія не знайдена: {session_id}")
|
378 |
+
return False
|
379 |
+
|
380 |
+
metadata_file = session_dir / "metadata.json"
|
381 |
+
|
382 |
+
try:
|
383 |
+
with open(metadata_file, "w", encoding="utf-8") as f:
|
384 |
+
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
385 |
+
return True
|
386 |
+
except Exception as e:
|
387 |
+
logger.error(f"Помилка при збереженні метаданих сесії {session_id}: {e}")
|
388 |
+
return False
|
389 |
+
|
390 |
+
def _get_session_metadata(self, session_id):
|
391 |
+
"""
|
392 |
+
Отримання метаданих сесії.
|
393 |
+
|
394 |
+
Args:
|
395 |
+
session_id (str): Ідентифікатор сесії
|
396 |
+
|
397 |
+
Returns:
|
398 |
+
dict: Метадані сесії або None у випадку помилки
|
399 |
+
"""
|
400 |
+
session_dir = self.base_dir / session_id
|
401 |
+
metadata_file = session_dir / "metadata.json"
|
402 |
+
|
403 |
+
if not metadata_file.exists():
|
404 |
+
logger.warning(f"Метадані сесії не знайдені: {session_id}")
|
405 |
+
return None
|
406 |
+
|
407 |
+
try:
|
408 |
+
with open(metadata_file, "r", encoding="utf-8") as f:
|
409 |
+
metadata = json.load(f)
|
410 |
+
return metadata
|
411 |
+
except Exception as e:
|
412 |
+
logger.error(f"Помилка при читанні метаданих сесії {session_id}: {e}")
|
413 |
+
return None
|
414 |
+
|
415 |
+
def _update_session_access_time(self, session_id):
|
416 |
+
"""
|
417 |
+
Оновлення часу останнього доступу до сесії.
|
418 |
+
|
419 |
+
Args:
|
420 |
+
session_id (str): Ідентифікатор сесії
|
421 |
+
|
422 |
+
Returns:
|
423 |
+
bool: True, якщо час доступу успішно оновлено, False у випадку помилки
|
424 |
+
"""
|
425 |
+
metadata = self._get_session_metadata(session_id)
|
426 |
+
if not metadata:
|
427 |
+
return False
|
428 |
+
|
429 |
+
metadata["last_accessed"] = datetime.now().isoformat()
|
430 |
+
return self._save_session_metadata(session_id, metadata)
|
431 |
+
|
432 |
+
@staticmethod
|
433 |
+
def _generate_file_hash(file_path):
|
434 |
+
"""
|
435 |
+
Генерує хеш для файлу на основі його вмісту або шляху.
|
436 |
+
|
437 |
+
Args:
|
438 |
+
file_path (str): Шлях до файлу
|
439 |
+
|
440 |
+
Returns:
|
441 |
+
str: Хеш файлу
|
442 |
+
"""
|
443 |
+
try:
|
444 |
+
if os.path.exists(file_path):
|
445 |
+
# Для невеликих файлів використовуємо вміст файлу
|
446 |
+
if os.path.getsize(file_path) < 10 * 1024 * 1024: # < 10 MB
|
447 |
+
sha256 = hashlib.sha256()
|
448 |
+
with open(file_path, "rb") as f:
|
449 |
+
for byte_block in iter(lambda: f.read(4096), b""):
|
450 |
+
sha256.update(byte_block)
|
451 |
+
return sha256.hexdigest()
|
452 |
+
else:
|
453 |
+
# Для великих файлів використовуємо шлях, розмір і час модифікації
|
454 |
+
file_stat = os.stat(file_path)
|
455 |
+
hash_input = f"{file_path}_{file_stat.st_size}_{file_stat.st_mtime}"
|
456 |
+
return hashlib.md5(hash_input.encode()).hexdigest()
|
457 |
+
else:
|
458 |
+
# Якщо файл не існує, повертаємо хеш шляху
|
459 |
+
return hashlib.md5(file_path.encode()).hexdigest()
|
460 |
+
except Exception as e:
|
461 |
+
logger.warning(f"Помилка при генерації хешу файлу {file_path}: {e}")
|
462 |
+
# У випадку помилки, повертаємо хеш шляху
|
463 |
+
return hashlib.md5(str(file_path).encode()).hexdigest()
|
modules/data_management/unified_index_manager.py
ADDED
@@ -0,0 +1,571 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import logging
|
3 |
+
import json
|
4 |
+
import shutil
|
5 |
+
from pathlib import Path
|
6 |
+
import pandas as pd
|
7 |
+
from datetime import datetime, timedelta
|
8 |
+
|
9 |
+
# Імпорт LlamaIndex компонентів
|
10 |
+
from llama_index.core import (
|
11 |
+
VectorStoreIndex,
|
12 |
+
Document,
|
13 |
+
StorageContext,
|
14 |
+
load_index_from_storage,
|
15 |
+
Settings
|
16 |
+
)
|
17 |
+
from llama_index.core.node_parser import TokenTextSplitter
|
18 |
+
from llama_index.retrievers.bm25 import BM25Retriever
|
19 |
+
from llama_index.vector_stores.faiss import FaissVectorStore
|
20 |
+
from llama_index.core.schema import TextNode
|
21 |
+
from llama_index.core.storage.docstore import SimpleDocumentStore
|
22 |
+
import faiss
|
23 |
+
|
24 |
+
from modules.config.paths import INDICES_DIR
|
25 |
+
from modules.data_management.hash_utils import generate_data_hash
|
26 |
+
from modules.data_management.index_utils import (
|
27 |
+
check_indexing_availability,
|
28 |
+
initialize_embedding_model,
|
29 |
+
check_index_integrity
|
30 |
+
)
|
31 |
+
|
32 |
+
from modules.config.ai_settings import (
|
33 |
+
get_metadata_csv,
|
34 |
+
)
|
35 |
+
|
36 |
+
# Встановлюємо формат збереження на бінарний (не JSON)
|
37 |
+
Settings.persist_json_format = False
|
38 |
+
|
39 |
+
logger = logging.getLogger(__name__)
|
40 |
+
|
41 |
+
class UnifiedIndexManager:
|
42 |
+
"""
|
43 |
+
Уніфікований менеджер для створення та управління індексами даних.
|
44 |
+
"""
|
45 |
+
def __init__(self, base_indices_dir=None):
|
46 |
+
"""
|
47 |
+
Ініціалізація менеджера індексів.
|
48 |
+
|
49 |
+
Args:
|
50 |
+
base_indices_dir (str, optional): Базова директорія для зберігання індексів
|
51 |
+
"""
|
52 |
+
self.base_indices_dir = Path(base_indices_dir) if base_indices_dir else INDICES_DIR
|
53 |
+
self.base_indices_dir.mkdir(exist_ok=True, parents=True)
|
54 |
+
|
55 |
+
# Перевірка доступності модулів для індексування
|
56 |
+
self.indexing_available = check_indexing_availability("temp/indices")
|
57 |
+
if not self.indexing_available:
|
58 |
+
logger.warning("Функціональність індексування недоступна. Встановіть необхідні пакети.")
|
59 |
+
|
60 |
+
def get_or_create_indices(self, df, session_id=None):
|
61 |
+
"""
|
62 |
+
Отримання або створення індексів для даних.
|
63 |
+
|
64 |
+
Args:
|
65 |
+
df (pandas.DataFrame): DataFrame з даними
|
66 |
+
session_id (str, optional): Ідентифікатор сесії
|
67 |
+
|
68 |
+
Returns:
|
69 |
+
dict: Інформація про індекси
|
70 |
+
"""
|
71 |
+
if not self.indexing_available:
|
72 |
+
return {"error": "Функціональність індексування недоступна. Встановіть необхідні пакети."}
|
73 |
+
|
74 |
+
try:
|
75 |
+
# Генеруємо хеш для даних
|
76 |
+
data_hash = generate_data_hash(df, key_columns=['Issue key', 'Summary', 'Status', 'Issue Type', 'Created', 'Updated'])
|
77 |
+
|
78 |
+
if not data_hash:
|
79 |
+
return {"error": "Не вдалося згенерувати хеш для даних"}
|
80 |
+
|
81 |
+
# Перевіряємо, чи існують індекси для цих даних
|
82 |
+
existing_indices = self._find_indices_by_hash(data_hash)
|
83 |
+
|
84 |
+
if existing_indices:
|
85 |
+
# Перевіряємо цілісність індексів
|
86 |
+
is_valid, message = check_index_integrity(existing_indices)
|
87 |
+
if is_valid:
|
88 |
+
logger.info(f"Знайдено існуючі індекси для даних з хешем {data_hash}")
|
89 |
+
return {
|
90 |
+
"success": True,
|
91 |
+
"indices_dir": str(existing_indices),
|
92 |
+
"data_hash": data_hash,
|
93 |
+
"reused_existing": True
|
94 |
+
}
|
95 |
+
else:
|
96 |
+
logger.warning(f"Знайдено індекси з відповідним хешем, але вони не пройшли перевірку цілісності: {message}")
|
97 |
+
|
98 |
+
# Створюємо нові індекси
|
99 |
+
# Визначаємо директорію для індексів
|
100 |
+
if session_id:
|
101 |
+
indices_path = self.base_indices_dir / session_id
|
102 |
+
else:
|
103 |
+
# Якщо не вказано session_id, використовуємо поточну дату і час
|
104 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
105 |
+
indices_path = self.base_indices_dir / timestamp
|
106 |
+
|
107 |
+
indices_path.mkdir(exist_ok=True, parents=True)
|
108 |
+
|
109 |
+
# Створюємо нові індекси
|
110 |
+
result = self._create_new_indices(indices_path, session_id, data_hash, df)
|
111 |
+
|
112 |
+
# Форматуємо результат
|
113 |
+
if isinstance(result, dict):
|
114 |
+
return result
|
115 |
+
else:
|
116 |
+
return {
|
117 |
+
"success": True,
|
118 |
+
"indices_dir": str(indices_path),
|
119 |
+
"data_hash": data_hash
|
120 |
+
}
|
121 |
+
|
122 |
+
except Exception as e:
|
123 |
+
logger.error(f"Помилка при отриманні або створенні індексів: {e}")
|
124 |
+
import traceback
|
125 |
+
logger.error(traceback.format_exc())
|
126 |
+
return {"error": f"Помилка при отриманні або створенні індексів: {str(e)}"}
|
127 |
+
|
128 |
+
def _find_indices_by_hash(self, data_hash):
|
129 |
+
"""
|
130 |
+
Пошук існуючих індексів за хешем даних.
|
131 |
+
|
132 |
+
Args:
|
133 |
+
data_hash (str): Хеш даних
|
134 |
+
|
135 |
+
Returns:
|
136 |
+
Path: Шлях до директорії з індексами або None, якщо не знайдено
|
137 |
+
"""
|
138 |
+
try:
|
139 |
+
# Перебираємо всі піддиректорії в базовій директорії індексів
|
140 |
+
for index_dir in self.base_indices_dir.iterdir():
|
141 |
+
if not index_dir.is_dir():
|
142 |
+
continue
|
143 |
+
|
144 |
+
# Перевіряємо метадані
|
145 |
+
metadata_file = index_dir / "metadata.json"
|
146 |
+
if not metadata_file.exists():
|
147 |
+
continue
|
148 |
+
|
149 |
+
try:
|
150 |
+
with open(metadata_file, "r", encoding="utf-8") as f:
|
151 |
+
metadata = json.load(f)
|
152 |
+
|
153 |
+
# Перевіряємо хеш
|
154 |
+
if metadata.get("data_hash") == data_hash:
|
155 |
+
return index_dir
|
156 |
+
except Exception as e:
|
157 |
+
logger.error(f"Помилка при перевірці метаданих {metadata_file}: {e}")
|
158 |
+
|
159 |
+
return None
|
160 |
+
|
161 |
+
except Exception as e:
|
162 |
+
logger.error(f"Помилка при пошуку індексів за хешем: {e}")
|
163 |
+
return None
|
164 |
+
|
165 |
+
def _create_new_indices(self, indices_path, session_id, data_hash, df):
|
166 |
+
"""
|
167 |
+
Створення нових індексів.
|
168 |
+
|
169 |
+
Args:
|
170 |
+
indices_path (Path): Шлях для збереження індексів
|
171 |
+
session_id (str): Ідентифікатор сесії
|
172 |
+
data_hash (str): Хеш даних
|
173 |
+
df (pandas.DataFrame): DataFrame з даними
|
174 |
+
|
175 |
+
Returns:
|
176 |
+
dict: Інформація про створені індекси
|
177 |
+
"""
|
178 |
+
try:
|
179 |
+
# Ініціалізуємо модель ембедингів
|
180 |
+
embed_model = initialize_embedding_model()
|
181 |
+
if not embed_model:
|
182 |
+
return {"error": "Не вдалося ініціалізувати модель ембедингів"}
|
183 |
+
|
184 |
+
# Отримуємо розмірність ембедингів
|
185 |
+
sample_embedding = embed_model.get_text_embedding("Test")
|
186 |
+
embedding_dim = len(sample_embedding)
|
187 |
+
logger.info(f"Розмірність ембедингів: {embedding_dim}")
|
188 |
+
|
189 |
+
# Конвертуємо DataFrame в документи
|
190 |
+
documents = self._convert_dataframe_to_documents(df)
|
191 |
+
if not documents:
|
192 |
+
return {"error": "Не вдалося конвертувати дані в документи"}
|
193 |
+
|
194 |
+
# Створюємо ноди з документів
|
195 |
+
nodes = [TextNode(text=doc.text, metadata=doc.metadata) for doc in documents]
|
196 |
+
|
197 |
+
# Створюємо FAISS індекс
|
198 |
+
faiss_index = faiss.IndexFlatL2(embedding_dim)
|
199 |
+
vector_store = FaissVectorStore(faiss_index=faiss_index)
|
200 |
+
|
201 |
+
# Створюємо документне сховище
|
202 |
+
docstore = SimpleDocumentStore()
|
203 |
+
docstore.add_documents(nodes)
|
204 |
+
|
205 |
+
# Створюємо контекст зберігання
|
206 |
+
storage_context = StorageContext.from_defaults(
|
207 |
+
docstore=docstore,
|
208 |
+
vector_store=vector_store
|
209 |
+
)
|
210 |
+
|
211 |
+
# Встановлюємо модель ембедингів
|
212 |
+
Settings.embed_model = embed_model
|
213 |
+
|
214 |
+
# Створюємо індекс
|
215 |
+
index = VectorStoreIndex.from_documents(
|
216 |
+
documents,
|
217 |
+
storage_context=storage_context
|
218 |
+
)
|
219 |
+
|
220 |
+
# Зберігаємо індекс у файл (бінарний формат)
|
221 |
+
index.storage_context.persist(str(indices_path))
|
222 |
+
|
223 |
+
# Створюємо BM25 retriever і зберігаємо його параметри
|
224 |
+
bm25_retriever = BM25Retriever.from_defaults(
|
225 |
+
docstore=docstore,
|
226 |
+
similarity_top_k=10
|
227 |
+
)
|
228 |
+
self._save_bm25_data(indices_path, bm25_retriever)
|
229 |
+
|
230 |
+
# Зберігаємо метадані
|
231 |
+
self._save_indices_metadata(indices_path, {
|
232 |
+
"session_id": session_id,
|
233 |
+
"created_at": datetime.now().isoformat(),
|
234 |
+
"data_hash": data_hash,
|
235 |
+
"documents_count": len(documents),
|
236 |
+
"nodes_count": len(nodes),
|
237 |
+
"rows_count": len(df),
|
238 |
+
"columns_count": len(df.columns),
|
239 |
+
"embedding_model": str(embed_model),
|
240 |
+
"embedding_dim": embedding_dim,
|
241 |
+
"storage_format": "binary"
|
242 |
+
})
|
243 |
+
|
244 |
+
# Створюємо маркерний файл для перевірки валідності індексів
|
245 |
+
with open(indices_path / "indices.valid", "w") as f:
|
246 |
+
f.write(f"Indices created at {datetime.now().isoformat()}")
|
247 |
+
|
248 |
+
logger.info(f"Індекси успішно створено в {indices_path}")
|
249 |
+
|
250 |
+
# Зберігаємо шлях глобально, якщо доступно
|
251 |
+
self._save_indices_path_globally(str(indices_path))
|
252 |
+
|
253 |
+
return {
|
254 |
+
"success": True,
|
255 |
+
"indices_dir": str(indices_path),
|
256 |
+
"data_hash": data_hash,
|
257 |
+
"documents_count": len(documents),
|
258 |
+
"nodes_count": len(nodes),
|
259 |
+
"rows_count": len(df),
|
260 |
+
"reused_existing": False
|
261 |
+
}
|
262 |
+
|
263 |
+
except Exception as e:
|
264 |
+
logger.error(f"Помилка при створенні нових індексів: {e}")
|
265 |
+
import traceback
|
266 |
+
logger.error(traceback.format_exc())
|
267 |
+
return {"error": f"Помилка при створенні нових індексів: {str(e)}"}
|
268 |
+
|
269 |
+
def _save_indices_metadata(self, indices_path, metadata):
|
270 |
+
"""Зберігає метадані індексів у файл."""
|
271 |
+
try:
|
272 |
+
with open(indices_path / "metadata.json", "w", encoding="utf-8") as f:
|
273 |
+
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
274 |
+
return True
|
275 |
+
except Exception as e:
|
276 |
+
logger.error(f"Помилка при збереженні метаданих: {e}")
|
277 |
+
return False
|
278 |
+
|
279 |
+
def _save_indices_path_globally(self, indices_path):
|
280 |
+
"""Зберігає шлях до індексів у глобальних об'єктах (app, index_manager)."""
|
281 |
+
try:
|
282 |
+
import builtins
|
283 |
+
if hasattr(builtins, 'app'):
|
284 |
+
builtins.app.indices_path = indices_path
|
285 |
+
logger.info(f"Шлях до індексів збережено глобально: {indices_path}")
|
286 |
+
|
287 |
+
# Якщо також є глобальний index_manager, зберігаємо в ньому
|
288 |
+
if hasattr(builtins, 'index_manager'):
|
289 |
+
builtins.index_manager.last_indices_path = indices_path
|
290 |
+
return True
|
291 |
+
except Exception as e:
|
292 |
+
logger.warning(f"Не вдалося зберегти шлях до індексів глобально: {e}")
|
293 |
+
return False
|
294 |
+
|
295 |
+
def _convert_dataframe_to_documents(self, df):
|
296 |
+
"""
|
297 |
+
Конвертує DataFrame у документи для індексування.
|
298 |
+
Кожен документ представляє один рядок CSV з усіма його полями.
|
299 |
+
"""
|
300 |
+
try:
|
301 |
+
# Перевірка типу даних
|
302 |
+
if not hasattr(df, 'iterrows'):
|
303 |
+
logger.error(f"Отримано не DataFrame: {type(df)}")
|
304 |
+
return None
|
305 |
+
|
306 |
+
# Конвертація в документи
|
307 |
+
documents = []
|
308 |
+
for idx, row in df.iterrows():
|
309 |
+
# Формуємо текст документа, включаючи всі основні поля
|
310 |
+
text_parts = []
|
311 |
+
|
312 |
+
# Додаємо основні поля
|
313 |
+
key_fields = [
|
314 |
+
('Issue key', 'Ключ задачі'),
|
315 |
+
('Summary', 'Заголовок'),
|
316 |
+
('Issue Type', 'Тип задачі'),
|
317 |
+
('Status', 'Статус'),
|
318 |
+
('Priority', 'Пріоритет'),
|
319 |
+
('Assignee', 'Виконавець'),
|
320 |
+
('Reporter', 'Автор'),
|
321 |
+
('Created', 'Створено'),
|
322 |
+
('Updated', 'Оновлено'),
|
323 |
+
('Project name', 'Проект')
|
324 |
+
]
|
325 |
+
|
326 |
+
for field, title in key_fields:
|
327 |
+
if field in row and pd.notna(row[field]):
|
328 |
+
text_parts.append(f"{title}: {str(row[field])}")
|
329 |
+
|
330 |
+
# Додаємо опис, якщо він є
|
331 |
+
if 'Description' in row and pd.notna(row['Description']):
|
332 |
+
text_parts.append(f"Опис: {str(row['Description'])}")
|
333 |
+
|
334 |
+
# Додаємо коментарі, якщо вони є
|
335 |
+
comments = []
|
336 |
+
for col in df.columns:
|
337 |
+
if col.startswith('Comment') and pd.notna(row[col]):
|
338 |
+
comments.append(str(row[col]))
|
339 |
+
|
340 |
+
if comments:
|
341 |
+
text_parts.append("Коментарі:")
|
342 |
+
for i, comment in enumerate(comments, 1):
|
343 |
+
text_parts.append(f"Коментар {i}: {comment}")
|
344 |
+
|
345 |
+
# Додаємо інформацію про зв'язки, якщо вона є
|
346 |
+
links = []
|
347 |
+
for col in df.columns:
|
348 |
+
if col.startswith('Outward issue link') and pd.notna(row[col]):
|
349 |
+
link_type = col.replace('Outward issue link (', '').replace(')', '')
|
350 |
+
links.append(f"{link_type}: {str(row[col])}")
|
351 |
+
|
352 |
+
if links:
|
353 |
+
text_parts.append("Зв'язки:")
|
354 |
+
for link in links:
|
355 |
+
text_parts.append(link)
|
356 |
+
|
357 |
+
# Додаємо користувацькі поля
|
358 |
+
custom_fields = []
|
359 |
+
for col in df.columns:
|
360 |
+
if (col.startswith('Custom field') or col.startswith('Sprint')) and pd.notna(row[col]):
|
361 |
+
field_name = col.replace('Custom field (', '').replace(')', '')
|
362 |
+
custom_fields.append(f"{field_name}: {str(row[col])}")
|
363 |
+
|
364 |
+
if custom_fields:
|
365 |
+
text_parts.append("Додаткові поля:")
|
366 |
+
for field in custom_fields:
|
367 |
+
text_parts.append(field)
|
368 |
+
|
369 |
+
# Об'єднуємо все в один текст
|
370 |
+
text = "\n".join(text_parts)
|
371 |
+
|
372 |
+
# Якщо текст порожній, використовуємо хоча б заголовок
|
373 |
+
if not text and 'Summary' in row and pd.notna(row['Summary']):
|
374 |
+
text = f"Заголовок: {str(row['Summary'])}"
|
375 |
+
elif not text:
|
376 |
+
text = f"Задача {idx}"
|
377 |
+
|
378 |
+
# Створюємо метадані - включаємо всі основні поля
|
379 |
+
metadata = get_metadata_csv(row, idx)
|
380 |
+
|
381 |
+
# Додаємо інформацію про зв'язки в метадані
|
382 |
+
if 'Outward issue link (Relates)' in row and pd.notna(row['Outward issue link (Relates)']):
|
383 |
+
metadata["related_issues"] = row['Outward issue link (Relates)']
|
384 |
+
|
385 |
+
# Створення документа
|
386 |
+
doc = Document(
|
387 |
+
text=text,
|
388 |
+
metadata=metadata
|
389 |
+
)
|
390 |
+
|
391 |
+
documents.append(doc)
|
392 |
+
|
393 |
+
logger.info(f"Створено {len(documents)} документів з DataFrame")
|
394 |
+
return documents
|
395 |
+
|
396 |
+
except Exception as e:
|
397 |
+
logger.error(f"Помилка при конвертації DataFrame в документи: {e}")
|
398 |
+
import traceback
|
399 |
+
logger.error(traceback.format_exc())
|
400 |
+
return []
|
401 |
+
|
402 |
+
def _save_bm25_data(self, indices_path, bm25_retriever):
|
403 |
+
"""
|
404 |
+
Збереження даних для BM25 retriever.
|
405 |
+
"""
|
406 |
+
try:
|
407 |
+
# Створюємо директорію для BM25
|
408 |
+
bm25_dir = indices_path / "bm25"
|
409 |
+
bm25_dir.mkdir(exist_ok=True)
|
410 |
+
|
411 |
+
# Зберігаємо параметри BM25
|
412 |
+
bm25_params = {
|
413 |
+
"similarity_top_k": bm25_retriever.similarity_top_k,
|
414 |
+
"alpha": getattr(bm25_retriever, "alpha", 0.75),
|
415 |
+
"beta": getattr(bm25_retriever, "beta", 0.75),
|
416 |
+
"index_creation_time": datetime.now().isoformat()
|
417 |
+
}
|
418 |
+
|
419 |
+
with open(bm25_dir / "params.json", "w", encoding="utf-8") as f:
|
420 |
+
json.dump(bm25_params, f, ensure_ascii=False, indent=2)
|
421 |
+
|
422 |
+
logger.info(f"Дані BM25 збережено в {bm25_dir}")
|
423 |
+
return True
|
424 |
+
|
425 |
+
except Exception as e:
|
426 |
+
logger.error(f"Помилка при збереженні даних BM25: {e}")
|
427 |
+
return False
|
428 |
+
|
429 |
+
def load_indices(self, indices_dir):
|
430 |
+
"""Завантаження індексів з директорії."""
|
431 |
+
try:
|
432 |
+
# Перевірка наявності директорії
|
433 |
+
indices_path = Path(indices_dir)
|
434 |
+
if not indices_path.exists():
|
435 |
+
logger.error(f"Директорія індексів не існує: {indices_dir}")
|
436 |
+
return None, None
|
437 |
+
|
438 |
+
# Перевірка наявності маркерного файлу
|
439 |
+
marker_path = indices_path / "indices.valid"
|
440 |
+
if not marker_path.exists():
|
441 |
+
logger.warning(f"Файл маркера не знайдено в {indices_dir}. Індекси не завантажено.")
|
442 |
+
return None, None
|
443 |
+
|
444 |
+
try:
|
445 |
+
# Спробуємо завантажити vector_store
|
446 |
+
vector_store = FaissVectorStore.from_persist_dir(indices_dir)
|
447 |
+
|
448 |
+
# Створюємо контекст зберігання
|
449 |
+
storage_context = StorageContext.from_defaults(
|
450 |
+
vector_store=vector_store,
|
451 |
+
persist_dir=indices_dir
|
452 |
+
)
|
453 |
+
|
454 |
+
# Завантажуємо індекс
|
455 |
+
index = load_index_from_storage(
|
456 |
+
storage_context=storage_context,
|
457 |
+
index_cls=VectorStoreIndex
|
458 |
+
)
|
459 |
+
|
460 |
+
# Створюємо BM25 retriever
|
461 |
+
bm25_retriever = BM25Retriever.from_defaults(
|
462 |
+
docstore=storage_context.docstore,
|
463 |
+
similarity_top_k=10
|
464 |
+
)
|
465 |
+
|
466 |
+
# Перевіряємо наявність параметрів BM25
|
467 |
+
bm25_params_path = indices_path / "bm25" / "params.json"
|
468 |
+
if bm25_params_path.exists():
|
469 |
+
try:
|
470 |
+
with open(bm25_params_path, "r", encoding="utf-8") as f:
|
471 |
+
bm25_params = json.load(f)
|
472 |
+
|
473 |
+
if "similarity_top_k" in bm25_params:
|
474 |
+
bm25_retriever.similarity_top_k = bm25_params["similarity_top_k"]
|
475 |
+
except Exception as e:
|
476 |
+
logger.warning(f"Не вдалося завантажити параметри BM25: {e}")
|
477 |
+
|
478 |
+
logger.info(f"Індекси успішно завантажено з {indices_dir}")
|
479 |
+
return index, bm25_retriever
|
480 |
+
|
481 |
+
except Exception as e:
|
482 |
+
logger.error(f"Помилка при завантаженні індексів: {e}")
|
483 |
+
import traceback
|
484 |
+
logger.error(traceback.format_exc())
|
485 |
+
|
486 |
+
# Діагностичні повідомлення
|
487 |
+
logger.info(f"Файли у директорії {indices_dir}: {[f.name for f in indices_path.iterdir() if f.is_file()]}")
|
488 |
+
|
489 |
+
return None, None
|
490 |
+
|
491 |
+
except Exception as e:
|
492 |
+
logger.error(f"Помилка при завантаженні індексів: {e}")
|
493 |
+
return None, None
|
494 |
+
|
495 |
+
def cleanup_old_indices(self, max_age_days=7, max_indices=20):
|
496 |
+
"""
|
497 |
+
Очищення застарілих індексів.
|
498 |
+
|
499 |
+
Args:
|
500 |
+
max_age_days (int): Максимальний вік індексів у днях
|
501 |
+
max_indices (int): Максимальна кількість індексів для зберігання
|
502 |
+
|
503 |
+
Returns:
|
504 |
+
int: Кількість видалених директорій
|
505 |
+
"""
|
506 |
+
try:
|
507 |
+
# Збираємо інформацію про всі директорії індексів
|
508 |
+
index_dirs = []
|
509 |
+
|
510 |
+
for index_dir in self.base_indices_dir.iterdir():
|
511 |
+
if not index_dir.is_dir():
|
512 |
+
continue
|
513 |
+
|
514 |
+
# Перевіряємо метадані
|
515 |
+
metadata_file = index_dir / "metadata.json"
|
516 |
+
if not metadata_file.exists():
|
517 |
+
continue
|
518 |
+
|
519 |
+
try:
|
520 |
+
with open(metadata_file, "r", encoding="utf-8") as f:
|
521 |
+
metadata = json.load(f)
|
522 |
+
|
523 |
+
# Отримуємо час створення
|
524 |
+
created_at = metadata.get("created_at", "")
|
525 |
+
|
526 |
+
index_dirs.append({
|
527 |
+
"path": str(index_dir),
|
528 |
+
"created_at": created_at
|
529 |
+
})
|
530 |
+
except Exception as e:
|
531 |
+
logger.error(f"Помилка при перевірці метаданих {metadata_file}: {e}")
|
532 |
+
|
533 |
+
# Якщо немає директорій, виходимо
|
534 |
+
if not index_dirs:
|
535 |
+
return 0
|
536 |
+
|
537 |
+
# Сортуємо директорії за часом створення (від найновіших до найстаріших)
|
538 |
+
index_dirs.sort(key=lambda x: x["created_at"], reverse=True)
|
539 |
+
|
540 |
+
# Визначаємо директорії для видалення
|
541 |
+
dirs_to_delete = []
|
542 |
+
|
543 |
+
# 1. Залишаємо max_indices найновіших директорій
|
544 |
+
if len(index_dirs) > max_indices:
|
545 |
+
dirs_to_delete.extend(index_dirs[max_indices:])
|
546 |
+
|
547 |
+
# 2. Перевіряємо, чи є серед залишених застарілі директорії
|
548 |
+
cutoff_date = (datetime.now() - timedelta(days=max_age_days)).isoformat()
|
549 |
+
|
550 |
+
for index_info in index_dirs[:max_indices]:
|
551 |
+
if index_info["created_at"] < cutoff_date:
|
552 |
+
dirs_to_delete.append(index_info)
|
553 |
+
|
554 |
+
# Видаляємо директорії
|
555 |
+
deleted_count = 0
|
556 |
+
|
557 |
+
for dir_info in dirs_to_delete:
|
558 |
+
try:
|
559 |
+
dir_path = Path(dir_info["path"])
|
560 |
+
if dir_path.exists():
|
561 |
+
shutil.rmtree(dir_path)
|
562 |
+
logger.info(f"Видалено застарілу директорію індексів: {dir_path}")
|
563 |
+
deleted_count += 1
|
564 |
+
except Exception as e:
|
565 |
+
logger.error(f"Помилка при видаленні директорії {dir_info['path']}: {e}")
|
566 |
+
|
567 |
+
return deleted_count
|
568 |
+
|
569 |
+
except Exception as e:
|
570 |
+
logger.error(f"Помилка при очищенні застарілих індексів: {e}")
|
571 |
+
return 0
|
modules/interface/ai_assistant_ui.py
ADDED
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import gradio as gr
|
2 |
+
import logging
|
3 |
+
import os
|
4 |
+
|
5 |
+
logger = logging.getLogger("jira_assistant_interface")
|
6 |
+
|
7 |
+
def init_indices_handler(app):
|
8 |
+
"""
|
9 |
+
Обробник для кнопки ініціалізації індексів для гібридного чату.
|
10 |
+
Використовує безпосередньо логіку JiraHybridChat для створення індексів.
|
11 |
+
|
12 |
+
Args:
|
13 |
+
app: Екземпляр JiraAssistantApp
|
14 |
+
|
15 |
+
Returns:
|
16 |
+
str: HTML-повідомлення про результат
|
17 |
+
"""
|
18 |
+
if not hasattr(app, 'current_data') or app.current_data is None:
|
19 |
+
return "<p style='color:red;'>❌ Помилка: спочатку завантажте дані CSV</p>"
|
20 |
+
|
21 |
+
try:
|
22 |
+
# Функція для ініціалізації індексів через JiraHybridChat
|
23 |
+
def initialize_chat_indices():
|
24 |
+
try:
|
25 |
+
from modules.ai_analysis.jira_hybrid_chat import JiraHybridChat
|
26 |
+
|
27 |
+
# Визначаємо директорію для індексів
|
28 |
+
indices_dir = None
|
29 |
+
if hasattr(app, 'current_session_id') and app.current_session_id is not None:
|
30 |
+
indices_dir = f"temp/indices/{app.current_session_id}"
|
31 |
+
os.makedirs(indices_dir, exist_ok=True)
|
32 |
+
|
33 |
+
# Створюємо екземпляр JiraHybridChat
|
34 |
+
chat = JiraHybridChat(
|
35 |
+
indices_dir=indices_dir,
|
36 |
+
app=app
|
37 |
+
)
|
38 |
+
|
39 |
+
# Якщо хочемо примусово перезавантажити/створити індекси,
|
40 |
+
# викликаємо load_indices
|
41 |
+
success = chat.load_indices(indices_dir)
|
42 |
+
if not success:
|
43 |
+
return {"error": "Не вдалося створити або завантажити індекси"}
|
44 |
+
|
45 |
+
# Отримуємо потрібні посилання на створені ретривери/індекси
|
46 |
+
vector_index = chat.index
|
47 |
+
bm25_retriever = chat.retriever_bm25
|
48 |
+
|
49 |
+
if not vector_index or not bm25_retriever:
|
50 |
+
return {"error": "Не вдалося створити індекси"}
|
51 |
+
|
52 |
+
# Зберігаємо шлях до індексів
|
53 |
+
app.indices_path = indices_dir
|
54 |
+
|
55 |
+
# Очищуємо кеш чату для перезавантаження з новими індексами
|
56 |
+
if hasattr(JiraHybridChat, 'chat_instances_cache'):
|
57 |
+
JiraHybridChat.chat_instances_cache = {}
|
58 |
+
|
59 |
+
return {
|
60 |
+
"success": True,
|
61 |
+
"indices_dir": indices_dir
|
62 |
+
}
|
63 |
+
|
64 |
+
except Exception as e:
|
65 |
+
import traceback
|
66 |
+
logger.error(f"Помилка при ініціалізації індексів: {e}\n{traceback.format_exc()}")
|
67 |
+
return {"error": str(e)}
|
68 |
+
|
69 |
+
# Викликаємо функцію ініціалізації
|
70 |
+
result = initialize_chat_indices()
|
71 |
+
|
72 |
+
if "error" in result:
|
73 |
+
return f"<p style='color:red;'>❌ Помилка при створенні індексів: {result['error']}</p>"
|
74 |
+
|
75 |
+
# Формуємо HTML для відображення результату
|
76 |
+
html_result = f"""
|
77 |
+
<div style='background-color:#e6f7e6; padding:15px; border-left:4px solid #28a745; border-radius:5px;'>
|
78 |
+
<p style='color:#28a745; font-weight:bold; font-size:16px;'>✅ Індекси успішно створено!</p>
|
79 |
+
<p>Директорія індексів: {result.get('indices_dir')}</p>
|
80 |
+
<p><b>Тепер можна використовувати гібридний чат!</b></p>
|
81 |
+
</div>
|
82 |
+
"""
|
83 |
+
|
84 |
+
return html_result
|
85 |
+
|
86 |
+
except Exception as e:
|
87 |
+
import traceback
|
88 |
+
error_details = traceback.format_exc()
|
89 |
+
logger.error(f"Помилка при ініціалізації індексів: {e}\n{error_details}")
|
90 |
+
return f"<p style='color:red;'>❌ Помилка при ініціалізації індексів: {str(e)}</p>"
|
91 |
+
|
92 |
+
def create_ai_assistant_tab(app):
|
93 |
+
"""
|
94 |
+
Створює вкладку 'AI Асистенти' у Gradio інтерфейсі.
|
95 |
+
Спроба завантажити або модифікований, або стандартний AI асистент.
|
96 |
+
Якщо імпорт не вдається, показується повідомлення про залежності.
|
97 |
+
"""
|
98 |
+
with gr.Tab("AI Асистенти"):
|
99 |
+
try:
|
100 |
+
# Додаємо секцію для ініціалізації індексів
|
101 |
+
gr.Markdown("## Ініціалізація індексів для гібридного пошуку")
|
102 |
+
gr.Markdown("""
|
103 |
+
Для роботи гібридного чату потрібно створити індекси FAISS і BM25.
|
104 |
+
Це потрібно зробити один раз після завантаження нових даних.
|
105 |
+
Кожен рядок CSV буде конвертовано в окрему ноду для пошуку.
|
106 |
+
""")
|
107 |
+
|
108 |
+
init_indices_btn = gr.Button("Ініціалізувати індекси", variant="primary")
|
109 |
+
indices_status = gr.HTML(label="Статус індексів")
|
110 |
+
|
111 |
+
# Прив'язуємо обробник до кнопки
|
112 |
+
init_indices_btn.click(
|
113 |
+
fn=lambda: init_indices_handler(app),
|
114 |
+
inputs=[],
|
115 |
+
outputs=[indices_status]
|
116 |
+
)
|
117 |
+
|
118 |
+
# Спробуємо модифіковану версію
|
119 |
+
try:
|
120 |
+
from modules.ai_analysis.ai_assistant_integration_mod import setup_ai_assistant_tab
|
121 |
+
setup_ai_assistant_tab(app, gr)
|
122 |
+
logger.info("Успішно завантажено модифіковану версію AI асистента")
|
123 |
+
except ImportError:
|
124 |
+
logger.info("Помилка завантаження модифікованої версії AI асистента")
|
125 |
+
|
126 |
+
except ImportError as e:
|
127 |
+
logger.error(f"Помилка імпорту модулів для AI асистента: {e}")
|
128 |
+
gr.Markdown("## AI Асистенти для Jira")
|
129 |
+
gr.Markdown(f"""
|
130 |
+
### ⚠️ Потрібні додаткові залежності
|
131 |
+
|
132 |
+
Для роботи AI асистентів необхідно встановити додаткові бібліотеки:
|
133 |
+
|
134 |
+
```bash
|
135 |
+
pip install llama-index-llms-gemini llama-index llama-index-embeddings-openai llama-index-retrievers-bm25 llama-index-vector-stores-faiss faiss-cpu tiktoken
|
136 |
+
```
|
137 |
+
|
138 |
+
Помилка: {str(e)}
|
139 |
+
""")
|
modules/interface/csv_analysis_ui.py
ADDED
@@ -0,0 +1,551 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import gradio as gr
|
3 |
+
import logging
|
4 |
+
from modules.interface.local_data_helper import LocalDataHelper
|
5 |
+
|
6 |
+
from datetime import datetime
|
7 |
+
|
8 |
+
logger = logging.getLogger("jira_assistant_interface")
|
9 |
+
|
10 |
+
def simplified_analyze_csv(file_obj, inactive_days, app):
|
11 |
+
"""
|
12 |
+
Спрощений аналіз CSV-файлу, викликає методи app для аналізу (без індексування).
|
13 |
+
"""
|
14 |
+
if file_obj is None:
|
15 |
+
return "Помилка: файл не вибрано"
|
16 |
+
|
17 |
+
from pathlib import Path
|
18 |
+
import shutil
|
19 |
+
import pandas as pd
|
20 |
+
|
21 |
+
try:
|
22 |
+
logger.info(f"Отримано файл: {file_obj.name}, тип: {type(file_obj)}")
|
23 |
+
|
24 |
+
# Створення директорій
|
25 |
+
Path("temp/indices").mkdir(exist_ok=True, parents=True)
|
26 |
+
data_dir = Path("data")
|
27 |
+
data_dir.mkdir(exist_ok=True, parents=True)
|
28 |
+
|
29 |
+
# Формування шляху для збереження
|
30 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
31 |
+
base_dir = os.path.dirname(os.path.abspath(__file__))
|
32 |
+
temp_file_path = os.path.join(base_dir, "../../data", f"imported_data_{timestamp}.csv")
|
33 |
+
|
34 |
+
logger.info(f"Шлях для збереження: {temp_file_path}")
|
35 |
+
logger.info(f"Робоча директорія: {os.getcwd()}")
|
36 |
+
|
37 |
+
# Копіюємо/записуємо файл
|
38 |
+
if hasattr(file_obj, 'name'):
|
39 |
+
source_path = file_obj.name
|
40 |
+
shutil.copy2(source_path, temp_file_path)
|
41 |
+
logger.info(f"Файл скопійовано з {source_path} у {temp_file_path}")
|
42 |
+
else:
|
43 |
+
with open(temp_file_path, "wb") as f:
|
44 |
+
f.write(file_obj.read())
|
45 |
+
logger.info(f"Файл створено у {temp_file_path}")
|
46 |
+
|
47 |
+
if not os.path.exists(temp_file_path):
|
48 |
+
logger.error(f"Помилка: файл {temp_file_path} не було створено")
|
49 |
+
return "Помилка: не вдалося створити файл даних"
|
50 |
+
|
51 |
+
file_size = os.path.getsize(temp_file_path)
|
52 |
+
logger.info(f"Розмір файлу: {file_size} байт")
|
53 |
+
if file_size == 0:
|
54 |
+
logger.error("Помилка: порожній файл")
|
55 |
+
return "Помилка: файл порожній"
|
56 |
+
|
57 |
+
# Перевірка, що CSV читається
|
58 |
+
try:
|
59 |
+
df_test = pd.read_csv(temp_file_path)
|
60 |
+
logger.info(f"Файл успішно прочитано. Кількість рядків: {len(df_test)}, колонок: {len(df_test.columns)}")
|
61 |
+
app.current_data = df_test
|
62 |
+
except Exception as csv_err:
|
63 |
+
logger.error(f"Помилка при читанні CSV: {csv_err}")
|
64 |
+
|
65 |
+
# Виклик методу аналізу без AI і без індексування
|
66 |
+
result = app.analyze_csv_file(
|
67 |
+
temp_file_path,
|
68 |
+
inactive_days=inactive_days,
|
69 |
+
include_ai=False,
|
70 |
+
skip_indexing=True # Важливо: пропускаємо створення індексів
|
71 |
+
)
|
72 |
+
|
73 |
+
if result.get("error"):
|
74 |
+
logger.error(f"Помилка аналізу: {result.get('error')}")
|
75 |
+
return result.get("error")
|
76 |
+
|
77 |
+
report = result.get("report", "")
|
78 |
+
app.last_loaded_csv = temp_file_path
|
79 |
+
logger.info(f"Шлях до файлу збережено в app.last_loaded_csv: {app.last_loaded_csv}")
|
80 |
+
|
81 |
+
if not os.path.exists(app.last_loaded_csv):
|
82 |
+
logger.error(f"Помилка: файл {app.last_loaded_csv} зник після аналізу")
|
83 |
+
return "Файл проаналізовано, але не збережено для подальшого використання. Спробуйте ще раз."
|
84 |
+
|
85 |
+
# Логування вмісту директорії data
|
86 |
+
try:
|
87 |
+
logger.info(f"Вміст директорії data: {os.listdir(os.path.join(base_dir, '../../data'))}")
|
88 |
+
except Exception as dir_err:
|
89 |
+
logger.error(f"Не вдалося отримати вміст директорії: {dir_err}")
|
90 |
+
|
91 |
+
return report
|
92 |
+
|
93 |
+
except Exception as e:
|
94 |
+
import traceback
|
95 |
+
error_msg = f"Помилка аналізу: {str(e)}\n\n{traceback.format_exc()}"
|
96 |
+
logger.error(error_msg)
|
97 |
+
return error_msg
|
98 |
+
|
99 |
+
def local_files_analyze_csv(file_obj, inactive_days, app):
|
100 |
+
"""
|
101 |
+
Аналіз CSV з локальних файлів або через нове завантаження.
|
102 |
+
Якщо file_obj = None, використовуємо дані з app.last_loaded_csv.
|
103 |
+
"""
|
104 |
+
if file_obj is None:
|
105 |
+
if hasattr(app, 'current_data') and app.current_data is not None and \
|
106 |
+
hasattr(app, 'last_loaded_csv') and app.last_loaded_csv is not None:
|
107 |
+
try:
|
108 |
+
temp_file_path = app.last_loaded_csv
|
109 |
+
if not os.path.exists(temp_file_path):
|
110 |
+
return "Помилка: файл не знайдено. Спочатку ініціалізуйте дані."
|
111 |
+
|
112 |
+
# Аналіз без індексування
|
113 |
+
result = app.analyze_csv_file(
|
114 |
+
temp_file_path,
|
115 |
+
inactive_days=inactive_days,
|
116 |
+
include_ai=False,
|
117 |
+
skip_indexing=True # Важливо: пропускаємо створення індексів
|
118 |
+
)
|
119 |
+
if result.get("error"):
|
120 |
+
return result.get("error")
|
121 |
+
|
122 |
+
return result.get("report", "")
|
123 |
+
except Exception as e:
|
124 |
+
return f"Помилка аналізу: {str(e)}"
|
125 |
+
else:
|
126 |
+
return "Помилка: файл не вибрано. Спочатку ініціалізуйте дані або завантажте CSV файл."
|
127 |
+
|
128 |
+
return simplified_analyze_csv(file_obj, inactive_days, app)
|
129 |
+
|
130 |
+
def init_and_analyze(selected_files, uploaded_file, inactive_days, app, local_helper):
|
131 |
+
"""
|
132 |
+
Об'єднує ініціалізацію даних та аналіз без створення індексів FAISS/BM25:
|
133 |
+
1) Викликається initialize_data_without_indices для підготовки даних без індексування
|
134 |
+
2) Якщо ініціалізація успішна, викликається local_files_analyze_csv
|
135 |
+
|
136 |
+
Повертає об'єднаний звіт у форматі Markdown, який містить статус ініціалізації та результати аналізу.
|
137 |
+
"""
|
138 |
+
# КРОК 1: Ініціалізація - без створення індексів
|
139 |
+
status_md, data_info = initialize_data_without_indices(selected_files, uploaded_file, app, local_helper)
|
140 |
+
if data_info is None:
|
141 |
+
return status_md
|
142 |
+
|
143 |
+
# КРОК 2: Аналіз
|
144 |
+
analysis_report = local_files_analyze_csv(uploaded_file, inactive_days, app)
|
145 |
+
|
146 |
+
# Об'єднуємо результати (форматуємо як Markdown)
|
147 |
+
combined_md = (
|
148 |
+
f"{status_md}\n\n---\n\n"
|
149 |
+
"### Результати аналізу\n\n"
|
150 |
+
f"{analysis_report}"
|
151 |
+
)
|
152 |
+
return combined_md
|
153 |
+
|
154 |
+
def initialize_data_without_indices(selected_files, uploaded_file, app, local_helper):
|
155 |
+
"""
|
156 |
+
Модифікована версія initialize_data, яка не створює індекси FAISS/BM25.
|
157 |
+
Виконує тільки підготовку даних для аналізу.
|
158 |
+
|
159 |
+
Args:
|
160 |
+
selected_files (list): Список вибраних файлів
|
161 |
+
uploaded_file: Завантажений файл
|
162 |
+
app: Екземпляр JiraAssistantApp
|
163 |
+
local_helper: Екземпляр LocalDataHelper
|
164 |
+
|
165 |
+
Returns:
|
166 |
+
tuple: (status_html, data_info) - статус ініціалізації та інформація про дані
|
167 |
+
"""
|
168 |
+
try:
|
169 |
+
session_id = local_helper.get_or_create_session()
|
170 |
+
app.current_session_id = session_id
|
171 |
+
|
172 |
+
# Отримуємо інформацію про локальні файли
|
173 |
+
local_files_info = local_helper.data_manager.get_local_files()
|
174 |
+
local_files_dict = {info['name']: info['path'] for info in local_files_info}
|
175 |
+
|
176 |
+
# Визначаємо шляхи до вибраних файлів
|
177 |
+
selected_paths = []
|
178 |
+
for selected in selected_files:
|
179 |
+
file_name = selected.split(" (")[0].strip() if " (" in selected else selected.strip()
|
180 |
+
if file_name in local_files_dict:
|
181 |
+
selected_paths.append(local_files_dict[file_name])
|
182 |
+
|
183 |
+
# Обробка завантаженого файлу
|
184 |
+
uploaded_file_path = None
|
185 |
+
if uploaded_file:
|
186 |
+
if hasattr(uploaded_file, 'name'):
|
187 |
+
uploaded_file_path = uploaded_file.name
|
188 |
+
else:
|
189 |
+
uploaded_file_path = uploaded_file
|
190 |
+
|
191 |
+
# Перевірка наявності файлів
|
192 |
+
if not selected_paths and not uploaded_file_path:
|
193 |
+
return "<p style='color:red;'>Помилка: не вибрано жодного файлу для обробки</p>", None
|
194 |
+
|
195 |
+
# Ініціалізація даних без створення індексів
|
196 |
+
success, result_info = initialize_session_data_no_indices(
|
197 |
+
local_helper.data_manager,
|
198 |
+
session_id,
|
199 |
+
selected_paths,
|
200 |
+
uploaded_file_path
|
201 |
+
)
|
202 |
+
|
203 |
+
if not success:
|
204 |
+
error_msg = result_info.get("error", "Невідома помилка")
|
205 |
+
return f"<p style='color:red;'>Помилка при ініціалізації даних: {error_msg}</p>", None
|
206 |
+
|
207 |
+
# Зберігаємо результати в app
|
208 |
+
merged_df = result_info.get("merged_df")
|
209 |
+
if merged_df is not None:
|
210 |
+
app.current_data = merged_df
|
211 |
+
app.last_loaded_csv = result_info.get("merged_file")
|
212 |
+
|
213 |
+
# ВАЖЛ��ВО: НЕ встановлюємо шлях до індексів, щоб уникнути їх створення
|
214 |
+
# Це відрізняється від оригінальної функції initialize_data
|
215 |
+
logger.info("Успішна ініціалізація даних без створення індексів")
|
216 |
+
|
217 |
+
# Формуємо HTML-відповідь про успішну ініціалізацію
|
218 |
+
status_html = "<h3 style='color:green;'>✅ Дані успішно ініціалізовано</h3>"
|
219 |
+
status_html += f"<p>Об'єднано {result_info.get('source_files_count', 0)} файлів</p>"
|
220 |
+
status_html += f"<p>Загальна кількість рядків: {result_info.get('rows_count', 0)}</p>"
|
221 |
+
status_html += f"<p>Кількість колонок: {result_info.get('columns_count', 0)}</p>"
|
222 |
+
|
223 |
+
files_info = {
|
224 |
+
"session_id": session_id,
|
225 |
+
"merged_file": result_info.get("merged_file"),
|
226 |
+
"rows_count": result_info.get("rows_count", 0),
|
227 |
+
"columns_count": result_info.get("columns_count", 0),
|
228 |
+
"source_files_count": result_info.get("source_files_count", 0)
|
229 |
+
}
|
230 |
+
|
231 |
+
return status_html, files_info
|
232 |
+
|
233 |
+
except Exception as e:
|
234 |
+
logger.error(f"Помилка при ініціалізації даних без індексів: {e}")
|
235 |
+
import traceback
|
236 |
+
error_details = traceback.format_exc()
|
237 |
+
logger.error(error_details)
|
238 |
+
return f"<p style='color:red;'>Помилка при ініціалізації даних: {str(e)}</p>", None
|
239 |
+
|
240 |
+
def initialize_session_data_no_indices(data_manager, session_id, selected_paths, uploaded_file_path=None):
|
241 |
+
"""
|
242 |
+
Модифікована версія initialize_session_data, яка не створює індекси.
|
243 |
+
|
244 |
+
Args:
|
245 |
+
data_manager: Екземпляр DataManager
|
246 |
+
session_id (str): ID сесії
|
247 |
+
selected_paths (list): Список шляхів до вибраних файлів
|
248 |
+
uploaded_file_path (str, optional): Шлях до завантаженого файлу
|
249 |
+
|
250 |
+
Returns:
|
251 |
+
tuple: (success, result_info) - успішність операції та інформація про результат
|
252 |
+
"""
|
253 |
+
try:
|
254 |
+
# Копіюємо вибрані файли в сесію
|
255 |
+
copied_files = data_manager.copy_files_to_session(session_id, selected_paths)
|
256 |
+
|
257 |
+
# Додаємо завантажений файл, якщо він є
|
258 |
+
if uploaded_file_path and os.path.exists(uploaded_file_path):
|
259 |
+
# Копіюємо файл до сесії
|
260 |
+
session_data_dir = data_manager.session_manager.get_session_data_dir(session_id)
|
261 |
+
if not session_data_dir:
|
262 |
+
return False, {"error": "Не вдалося отримати директорію даних сесії"}
|
263 |
+
|
264 |
+
# Створюємо унікальне ім'я для завантаженого файлу
|
265 |
+
from pathlib import Path
|
266 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
267 |
+
dest_filename = f"uploaded_{timestamp}_{Path(uploaded_file_path).name}"
|
268 |
+
dest_path = session_data_dir / dest_filename
|
269 |
+
|
270 |
+
# Копіюємо файл
|
271 |
+
import shutil
|
272 |
+
shutil.copyfile(uploaded_file_path, dest_path)
|
273 |
+
|
274 |
+
# Додаємо інформацію про файл до сесії
|
275 |
+
data_manager.session_manager.add_data_file(
|
276 |
+
session_id,
|
277 |
+
str(dest_path),
|
278 |
+
file_type="uploaded",
|
279 |
+
description=f"Uploaded file: {Path(uploaded_file_path).name}"
|
280 |
+
)
|
281 |
+
|
282 |
+
copied_files.append(str(dest_path))
|
283 |
+
|
284 |
+
# Якщо немає файлів для обробки, повертаємо помилку
|
285 |
+
if not copied_files:
|
286 |
+
return False, {"error": "Не вибрано жодного файлу для обробки"}
|
287 |
+
|
288 |
+
# Завантажуємо дані з усіх файлів
|
289 |
+
loaded_data = data_manager.load_data_from_files(session_id, copied_files)
|
290 |
+
|
291 |
+
# Фільтруємо тільки успішно завантажені файли
|
292 |
+
valid_data = [(path, df) for path, df, success in loaded_data if success and df is not None]
|
293 |
+
|
294 |
+
if not valid_data:
|
295 |
+
return False, {"error": "Не вдалося завантажити жодного файлу"}
|
296 |
+
|
297 |
+
# Отримуємо список DataFrame
|
298 |
+
dataframes = [df for _, df in valid_data]
|
299 |
+
|
300 |
+
# Об'єднуємо дані
|
301 |
+
merged_df, output_path = data_manager.merge_dataframes(
|
302 |
+
session_id,
|
303 |
+
dataframes,
|
304 |
+
output_name=f"merged_data_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
305 |
+
)
|
306 |
+
|
307 |
+
if merged_df is None or not output_path:
|
308 |
+
return False, {"error": "Не вдалося об'єднати дані"}
|
309 |
+
|
310 |
+
result_info = {
|
311 |
+
"merged_file": output_path,
|
312 |
+
"rows_count": len(merged_df),
|
313 |
+
"columns_count": len(merged_df.columns),
|
314 |
+
"source_files_count": len(valid_data),
|
315 |
+
"merged_df": merged_df # Передаємо DataFrame для подальшого використання
|
316 |
+
}
|
317 |
+
|
318 |
+
logger.info(f"Дані успішно ініціалізовано без створення індексів: {output_path}")
|
319 |
+
return True, result_info
|
320 |
+
|
321 |
+
except Exception as e:
|
322 |
+
logger.error(f"Помилка при ініціалізації даних сесії {session_id}: {e}")
|
323 |
+
return False, {"error": f"Помилка при ініціалізації даних: {str(e)}"}
|
324 |
+
|
325 |
+
def cleanup_temp_data_handler(app):
|
326 |
+
"""
|
327 |
+
Обробник для кнопки очищення тимчасових даних.
|
328 |
+
Виконує очищення даних та скидає відповідні змінні в додатку.
|
329 |
+
|
330 |
+
Args:
|
331 |
+
app: Екземпляр JiraAssistantApp
|
332 |
+
|
333 |
+
Returns:
|
334 |
+
str: HTML-відформатований результат очищення
|
335 |
+
"""
|
336 |
+
try:
|
337 |
+
import builtins
|
338 |
+
from pathlib import Path
|
339 |
+
|
340 |
+
# Перевіряємо, чи є data_manager у додатку
|
341 |
+
if hasattr(app, 'data_manager'):
|
342 |
+
data_manager = app.data_manager
|
343 |
+
else:
|
344 |
+
# Створюємо новий екземпляр, якщо відсутній
|
345 |
+
from modules.data_management.data_manager import DataManager
|
346 |
+
data_manager = DataManager()
|
347 |
+
|
348 |
+
# Запам'ятовуємо стан перед очищенням
|
349 |
+
had_indices_path = hasattr(app, 'indices_path') and app.indices_path is not None
|
350 |
+
had_session_id = hasattr(app, 'current_session_id') and app.current_session_id is not None
|
351 |
+
had_loaded_csv = hasattr(app, 'last_loaded_csv') and app.last_loaded_csv is not None
|
352 |
+
|
353 |
+
# Виконуємо очищення
|
354 |
+
result = data_manager.cleanup_temp_data()
|
355 |
+
|
356 |
+
# Скидаємо змінні додатку, які вказують на видалені дані
|
357 |
+
reset_info = ""
|
358 |
+
|
359 |
+
# Скидаємо indices_path
|
360 |
+
if hasattr(app, 'indices_path'):
|
361 |
+
old_path = app.indices_path
|
362 |
+
app.indices_path = None
|
363 |
+
reset_info += f"<p>• Скинуто шлях до індексів: {old_path}</p>"
|
364 |
+
|
365 |
+
# Скидаємо current_session_id
|
366 |
+
if hasattr(app, 'current_session_id'):
|
367 |
+
old_session = app.current_session_id
|
368 |
+
app.current_session_id = None
|
369 |
+
reset_info += f"<p>• Скинуто ID сесії: {old_session}</p>"
|
370 |
+
|
371 |
+
# Скидаємо шлях до останнього завантаженого файлу, якщо він був у тимчасовій папці
|
372 |
+
if hasattr(app, 'last_loaded_csv') and app.last_loaded_csv:
|
373 |
+
last_file_path = Path(app.last_loaded_csv)
|
374 |
+
if any(temp_dir in str(last_file_path) for temp_dir in ["temp/", "reports/", "data/"]):
|
375 |
+
old_path = app.last_loaded_csv
|
376 |
+
app.last_loaded_csv = None
|
377 |
+
reset_info += f"<p>• Скинуто шлях до файлу CSV: {old_path}</p>"
|
378 |
+
|
379 |
+
# Також скидаємо current_data, якщо він був завантажений з цього файлу
|
380 |
+
if hasattr(app, 'current_data') and app.current_data is not None:
|
381 |
+
app.current_data = None
|
382 |
+
reset_info += "<p>• Очищено завантажені дані DataFrame</p>"
|
383 |
+
|
384 |
+
# Скидаємо кешовані індекси в глобальних об'єктах
|
385 |
+
try:
|
386 |
+
# Скидаємо глобальні змінні, якщо вони існують
|
387 |
+
if hasattr(builtins, 'app') and hasattr(builtins.app, 'indices_path'):
|
388 |
+
builtins.app.indices_path = None
|
389 |
+
reset_info += "<p>• Скинуто глобальний шлях до індексів</p>"
|
390 |
+
|
391 |
+
if hasattr(builtins, 'index_manager') and hasattr(builtins.index_manager, 'last_indices_path'):
|
392 |
+
builtins.index_manager.last_indices_path = None
|
393 |
+
reset_info += "<p>• Скинуто глобальний шлях до останніх індексів</p>"
|
394 |
+
|
395 |
+
# Якщо є кеш індексів в JiraHybridChat, очищаємо його
|
396 |
+
if hasattr(app, 'chat_instances_cache'):
|
397 |
+
app.chat_instances_cache = {}
|
398 |
+
reset_info += "<p>• Очищено кеш екземплярів чату</p>"
|
399 |
+
|
400 |
+
# Перевірка наявності статичного кешу у класі JiraHybridChat
|
401 |
+
from modules.ai_analysis.jira_hybrid_chat import JiraHybridChat
|
402 |
+
if hasattr(JiraHybridChat, 'chat_instances_cache') and JiraHybridChat.chat_instances_cache:
|
403 |
+
JiraHybridChat.chat_instances_cache = {}
|
404 |
+
reset_info += "<p>• Очищено статичний кеш чату</p>"
|
405 |
+
|
406 |
+
except Exception as e:
|
407 |
+
logger.warning(f"Помилка при очищенні глобальних змінних: {e}")
|
408 |
+
|
409 |
+
if result.get("success", False):
|
410 |
+
stats = result.get("stats", {})
|
411 |
+
|
412 |
+
# Формуємо HTML-відповідь
|
413 |
+
html_response = "<h3 style='color:green;'>✅ Тимчасові дані успішно очищено</h3>"
|
414 |
+
html_response += "<div style='background-color:#e9f7ef; padding:15px; border-radius:5px; margin-top:10px;'>"
|
415 |
+
html_response += "<p><b>Результати очищення:</b></p>"
|
416 |
+
html_response += f"<p>• Видалено тимчасових файлів: {stats.get('temp_files_removed', 0)}</p>"
|
417 |
+
html_response += f"<p>• Видалено директорій сесій: {stats.get('session_dirs_removed', 0)}</p>"
|
418 |
+
html_response += f"<p>• Видалено директорій індексів: {stats.get('indices_dirs_removed', 0)}</p>"
|
419 |
+
html_response += f"<p>• Видалено звітів і візуалізацій: {stats.get('reports_removed', 0)}</p>"
|
420 |
+
html_response += "</div>"
|
421 |
+
|
422 |
+
# Додаємо інформацію про скинуті змінні
|
423 |
+
if reset_info:
|
424 |
+
html_response += "<div style='background-color:#FDEBD0; padding:15px; border-radius:5px; margin-top:10px;'>"
|
425 |
+
html_response += "<p><b>Скинуто наступні посилання на дані:</b></p>"
|
426 |
+
html_response += reset_info
|
427 |
+
html_response += "</div>"
|
428 |
+
|
429 |
+
# Додаємо інформацію про стан перед/після
|
430 |
+
if had_indices_path or had_session_id or had_loaded_csv:
|
431 |
+
html_response += """
|
432 |
+
<div style='margin-top:15px;'>
|
433 |
+
<p><i>⚠️ Увага: Для подальшого аналізу потрібно заново ініціалізувати дані</i></p>
|
434 |
+
</div>
|
435 |
+
"""
|
436 |
+
|
437 |
+
return html_response
|
438 |
+
else:
|
439 |
+
error_msg = result.get("error", "Невідома помилка")
|
440 |
+
return f"<h3 style='color:red;'>❌ Помилка при очищенні тимчасових даних</h3><p>{error_msg}</p>"
|
441 |
+
|
442 |
+
except Exception as e:
|
443 |
+
import traceback
|
444 |
+
error_details = traceback.format_exc()
|
445 |
+
logger.error(f"Помилка при очищенні тимчасових даних: {e}\n{error_details}")
|
446 |
+
return f"<h3 style='color:red;'>❌ Помилка при очищенні тимчасових даних</h3><p>{str(e)}</p>"
|
447 |
+
|
448 |
+
def create_csv_analysis_tab(app):
|
449 |
+
"""
|
450 |
+
Створює вкладку "CSV Аналіз" у Gradio інтерфейсі:
|
451 |
+
- Завантаження файлів та перегляд локальних файлів.
|
452 |
+
- Об'єднаний аналіз: ініціалізація даних та аналіз через одну кнопку.
|
453 |
+
- Очищення тимчасових даних через кнопку.
|
454 |
+
В результаті звіт відображається як Markdown.
|
455 |
+
"""
|
456 |
+
with gr.Tab("CSV Аналіз"):
|
457 |
+
with gr.Row():
|
458 |
+
with gr.Column(scale=1):
|
459 |
+
gr.Markdown("### Завантаження CSV")
|
460 |
+
local_file_input = gr.File(label="Завантажити CSV файл Jira")
|
461 |
+
local_inactive_days = gr.Slider(
|
462 |
+
minimum=1, maximum=90, value=14, step=1,
|
463 |
+
label="Кількість днів для визначення неактивних тікетів"
|
464 |
+
)
|
465 |
+
|
466 |
+
gr.Markdown("### Локальні файли")
|
467 |
+
refresh_btn = gr.Button("Оновити список файлів", variant="secondary")
|
468 |
+
|
469 |
+
local_helper = LocalDataHelper(app)
|
470 |
+
local_files_list, local_files_info = local_helper.list_local_files()
|
471 |
+
|
472 |
+
local_files_dropdown = gr.Dropdown(
|
473 |
+
choices=local_files_list,
|
474 |
+
multiselect=True,
|
475 |
+
label="Виберіть файли з директорії current_data"
|
476 |
+
)
|
477 |
+
local_files_info_md = gr.Markdown(local_files_info)
|
478 |
+
|
479 |
+
gr.Markdown("### Перегляд вибраного файлу")
|
480 |
+
preview_file_dropdown = gr.Dropdown(
|
481 |
+
choices=local_files_list,
|
482 |
+
multiselect=False,
|
483 |
+
label="Виберіть файл для перегляду"
|
484 |
+
)
|
485 |
+
preview_btn = gr.Button("Переглянути", variant="secondary")
|
486 |
+
file_preview_md = gr.Markdown("Виберіть файл для перегляду")
|
487 |
+
|
488 |
+
# Секція для очищення тимчасових даних
|
489 |
+
with gr.Accordion("Обслуговування", open=False):
|
490 |
+
gr.Markdown("""
|
491 |
+
### Очищення тимчасових даних
|
492 |
+
|
493 |
+
Ця функція видаляє всі тимчасові файли і директорії, крім файлів у папці **current_data**.
|
494 |
+
|
495 |
+
**Будуть очищені:**
|
496 |
+
- Тимчасові файли індексів (temp/indices)
|
497 |
+
- Сесії користувачів (temp/sessions)
|
498 |
+
- Тимчасові звіти (reports)
|
499 |
+
- Інші файли в директорії temp
|
500 |
+
""")
|
501 |
+
cleanup_btn = gr.Button("🧹 Очистити тимчасові дані", variant="secondary")
|
502 |
+
cleanup_result = gr.HTML(label="Результат очищення", visible=True)
|
503 |
+
|
504 |
+
gr.Markdown("### Об'єднаний аналіз (Ініціалізація + Аналіз)")
|
505 |
+
init_analyze_btn = gr.Button("Ініціалізація та Аналіз", variant="primary")
|
506 |
+
|
507 |
+
with gr.Column(scale=2):
|
508 |
+
gr.Markdown("### Результати ініціалізації")
|
509 |
+
combined_output = gr.Markdown(
|
510 |
+
label="Об'єднаний звіт",
|
511 |
+
value="Тут буде відображено статус ініціалізації та результати аналізу"
|
512 |
+
)
|
513 |
+
|
514 |
+
gr.Markdown("""
|
515 |
+
<style>
|
516 |
+
/* Додаткова стилізація */
|
517 |
+
.cleanup-note {
|
518 |
+
margin-top: 15px;
|
519 |
+
padding: 10px;
|
520 |
+
background-color: #f8f9fa;
|
521 |
+
border-left: 4px solid #6c757d;
|
522 |
+
}
|
523 |
+
</style>
|
524 |
+
""")
|
525 |
+
|
526 |
+
def refresh_local_files():
|
527 |
+
files_list, files_info = local_helper.list_local_files()
|
528 |
+
return files_list, files_info, files_list
|
529 |
+
|
530 |
+
refresh_btn.click(
|
531 |
+
refresh_local_files,
|
532 |
+
inputs=[],
|
533 |
+
outputs=[local_files_dropdown, local_files_info_md, preview_file_dropdown]
|
534 |
+
)
|
535 |
+
preview_btn.click(
|
536 |
+
local_helper.get_file_preview,
|
537 |
+
inputs=[preview_file_dropdown],
|
538 |
+
outputs=[file_preview_md]
|
539 |
+
)
|
540 |
+
init_analyze_btn.click(
|
541 |
+
fn=lambda sel_files, upl_file, days: init_and_analyze(sel_files, upl_file, days, app, local_helper),
|
542 |
+
inputs=[local_files_dropdown, local_file_input, local_inactive_days],
|
543 |
+
outputs=[combined_output]
|
544 |
+
)
|
545 |
+
|
546 |
+
# Підключаємо обробник до кнопки очищення тимчасових даних
|
547 |
+
cleanup_btn.click(
|
548 |
+
fn=lambda: cleanup_temp_data_handler(app),
|
549 |
+
inputs=[],
|
550 |
+
outputs=[cleanup_result]
|
551 |
+
)
|
modules/interface/integrations_ui.py
ADDED
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import gradio as gr
|
2 |
+
import logging
|
3 |
+
|
4 |
+
logger = logging.getLogger("jira_assistant_interface")
|
5 |
+
|
6 |
+
def create_integrations_tab(app):
|
7 |
+
"""
|
8 |
+
Створює вкладку 'Інтеграції' у Gradio інтерфейсі.
|
9 |
+
"""
|
10 |
+
with gr.Tab("Інтеграції"):
|
11 |
+
gr.Markdown("## Інтеграції з зовнішніми системами")
|
12 |
+
gr.Markdown("⚠️ Ця функція буде доступна у наступних версіях")
|
13 |
+
|
14 |
+
with gr.Accordion("Slack інтеграція"):
|
15 |
+
slack_channel = gr.Textbox(
|
16 |
+
label="Slack канал",
|
17 |
+
placeholder="#project-updates"
|
18 |
+
)
|
19 |
+
slack_message = gr.Textbox(
|
20 |
+
label="Повідомлення",
|
21 |
+
placeholder="Тижневий звіт по проекту",
|
22 |
+
lines=3
|
23 |
+
)
|
24 |
+
slack_send_btn = gr.Button("Надіслати у Slack", interactive=False)
|
25 |
+
|
26 |
+
save_settings_btn = gr.Button("Зберегти налаштування", variant="primary")
|
27 |
+
settings_status = gr.Textbox(label="Статус")
|
28 |
+
|
29 |
+
# Заглушка
|
30 |
+
save_settings_btn.click(
|
31 |
+
lambda: "Налаштування збережено. Зміни набудуть чинності після перезапуску програми.",
|
32 |
+
inputs=[],
|
33 |
+
outputs=[settings_status]
|
34 |
+
)
|
modules/interface/jira_api_ui.py
ADDED
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import gradio as gr
|
2 |
+
import logging
|
3 |
+
|
4 |
+
logger = logging.getLogger("jira_assistant_interface")
|
5 |
+
|
6 |
+
def test_jira_connection_handler(url, username, api_token, app):
|
7 |
+
if not url or not username or not api_token:
|
8 |
+
return "Помилка: необхідно заповнити всі поля (URL, користувач, API токен)"
|
9 |
+
|
10 |
+
success = app.test_jira_connection(url, username, api_token)
|
11 |
+
if success:
|
12 |
+
return "✅ Успішне підключення до Jira API"
|
13 |
+
else:
|
14 |
+
return "❌ Помилка підключення до Jira. Перевірте введені дані."
|
15 |
+
|
16 |
+
def create_jira_api_tab(app):
|
17 |
+
"""
|
18 |
+
Створює вкладку 'Jira API' у Gradio інтерфейсі.
|
19 |
+
"""
|
20 |
+
with gr.Tab("Jira API"):
|
21 |
+
gr.Markdown("## Підключення до Jira API")
|
22 |
+
|
23 |
+
with gr.Row():
|
24 |
+
jira_url = gr.Textbox(
|
25 |
+
label="Jira URL",
|
26 |
+
placeholder="https://your-company.atlassian.net"
|
27 |
+
)
|
28 |
+
jira_username = gr.Textbox(
|
29 |
+
label="Ім'я користувача Jira",
|
30 |
+
placeholder="email@example.com"
|
31 |
+
)
|
32 |
+
jira_api_token = gr.Textbox(
|
33 |
+
label="Jira API Token",
|
34 |
+
type="password"
|
35 |
+
)
|
36 |
+
|
37 |
+
test_connection_btn = gr.Button("Тестувати з'єднання")
|
38 |
+
connection_status = gr.Textbox(label="Статус підключення")
|
39 |
+
|
40 |
+
test_connection_btn.click(
|
41 |
+
lambda u, usr, tkn: test_jira_connection_handler(u, usr, tkn, app),
|
42 |
+
inputs=[jira_url, jira_username, jira_api_token],
|
43 |
+
outputs=[connection_status]
|
44 |
+
)
|
45 |
+
|
46 |
+
gr.Markdown("## ⚠️ Ця функція буде доступна у наступних версіях")
|
modules/interface/local_data_helper.py
ADDED
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import logging
|
3 |
+
from pathlib import Path
|
4 |
+
import traceback
|
5 |
+
|
6 |
+
from modules.data_management.data_manager import DataManager
|
7 |
+
from modules.data_management.session_manager import SessionManager
|
8 |
+
from modules.data_management.index_manager import IndexManager
|
9 |
+
|
10 |
+
logger = logging.getLogger("jira_assistant_interface")
|
11 |
+
|
12 |
+
class LocalDataHelper:
|
13 |
+
"""
|
14 |
+
Клас для роботи з локальними CSV-файлами, сесіями та попереднім переглядом даних.
|
15 |
+
"""
|
16 |
+
def __init__(self, app, current_data_dir="current_data"):
|
17 |
+
self.app = app
|
18 |
+
self.current_data_dir = Path(current_data_dir)
|
19 |
+
self.current_data_dir.mkdir(exist_ok=True, parents=True)
|
20 |
+
|
21 |
+
# Ініціалізація менеджерів
|
22 |
+
self.session_manager = SessionManager()
|
23 |
+
self.data_manager = DataManager(current_data_dir, self.session_manager)
|
24 |
+
self.index_manager = IndexManager()
|
25 |
+
|
26 |
+
# Словник сесій для користувачів
|
27 |
+
self.user_sessions = {}
|
28 |
+
|
29 |
+
def get_or_create_session(self, user_id=None):
|
30 |
+
if not user_id:
|
31 |
+
import uuid
|
32 |
+
user_id = str(uuid.uuid4())
|
33 |
+
|
34 |
+
if user_id in self.user_sessions:
|
35 |
+
return self.user_sessions[user_id]
|
36 |
+
|
37 |
+
session_id = self.session_manager.create_session(user_id)
|
38 |
+
self.user_sessions[user_id] = session_id
|
39 |
+
|
40 |
+
logger.info(f"Створено нову сесію {session_id} для користувача {user_id}")
|
41 |
+
return session_id
|
42 |
+
|
43 |
+
def list_local_files(self):
|
44 |
+
try:
|
45 |
+
files_info = self.data_manager.get_local_files()
|
46 |
+
if not files_info:
|
47 |
+
return [], "<p>Не знайдено файлів CSV у директорії current_data.</p>"
|
48 |
+
|
49 |
+
# Використовуємо реальні дані, якщо доступні, інакше fallback до preview
|
50 |
+
files_list = [
|
51 |
+
f"{info['name']} ({info['size_kb']} KB, рядків: {info.get('rows_count', info.get('rows_preview', 'N/A'))}, колонок: {info.get('columns_count', info.get('columns_preview', 'N/A'))})"
|
52 |
+
for info in files_info
|
53 |
+
]
|
54 |
+
|
55 |
+
# Формуємо HTML
|
56 |
+
html_output = "<h3>Доступні файли в директорії current_data:</h3>"
|
57 |
+
html_output += "<table style='width:100%; border-collapse: collapse;'>"
|
58 |
+
html_output += "<tr style='background-color: #f2f2f2;'><th>Файл</th><th>Розмір</th><th>Змінено</th><th>Рядки</th><th>Колонки</th></tr>"
|
59 |
+
|
60 |
+
for info in files_info:
|
61 |
+
html_output += "<tr style='border-bottom: 1px solid #ddd;'>"
|
62 |
+
html_output += f"<td>{info['name']}</td>"
|
63 |
+
html_output += f"<td>{info['size_kb']} KB</td>"
|
64 |
+
html_output += f"<td>{info['modified']}</td>"
|
65 |
+
html_output += f"<td>{info.get('rows_count', info.get('rows_preview', 'N/A'))}</td>"
|
66 |
+
html_output += f"<td>{info.get('columns_count', info.get('columns_preview', 'N/A'))}</td>"
|
67 |
+
html_output += "</tr>"
|
68 |
+
|
69 |
+
html_output += "</table>"
|
70 |
+
|
71 |
+
# Приховане поле з шляхами
|
72 |
+
html_output += "<div id='file_paths' style='display:none;'>"
|
73 |
+
for info in files_info:
|
74 |
+
html_output += f"<div data-name='{info['name']}'>{info['path']}</div>"
|
75 |
+
html_output += "</div>"
|
76 |
+
|
77 |
+
return files_list, html_output
|
78 |
+
|
79 |
+
except Exception as e:
|
80 |
+
logger.error(f"Помилка при отриманні списку локальних файлів: {e}")
|
81 |
+
return [], f"<p>Помилка при отриманні списку файлів: {str(e)}</p>"
|
82 |
+
|
83 |
+
def get_file_preview(self, selected_file):
|
84 |
+
try:
|
85 |
+
if not selected_file:
|
86 |
+
return "<p>Виберіть файл для перегляду</p>"
|
87 |
+
|
88 |
+
local_files_info = self.data_manager.get_local_files()
|
89 |
+
local_files_dict = {info['name']: info['path'] for info in local_files_info}
|
90 |
+
|
91 |
+
file_name = selected_file.split(" (")[0].strip() if " (" in selected_file else selected_file.strip()
|
92 |
+
if file_name not in local_files_dict:
|
93 |
+
return f"<p>Файл {file_name} не знайдено</p>"
|
94 |
+
|
95 |
+
file_path = local_files_dict[file_name]
|
96 |
+
preview_info = self.data_manager.get_file_preview(file_path, max_rows=5)
|
97 |
+
|
98 |
+
if "error" in preview_info:
|
99 |
+
return f"<p style='color:red;'>Помилка при читанні файлу: {preview_info['error']}</p>"
|
100 |
+
|
101 |
+
# Формуємо HTML
|
102 |
+
html_output = f"<h3>Попередній перегляд файлу: {file_name}</h3>"
|
103 |
+
html_output += f"<p>Загальна кількість рядків: {preview_info['total_rows']}</p>"
|
104 |
+
html_output += f"<p>Кількість колонок: {preview_info['columns_count']}</p>"
|
105 |
+
|
106 |
+
html_output += "<table style='width:100%; border-collapse: collapse; font-size: 14px;'>"
|
107 |
+
# Заголовки
|
108 |
+
html_output += "<tr style='background-color: #4472C4; color: white;'>"
|
109 |
+
for col in preview_info['columns']:
|
110 |
+
html_output += f"<th style='padding: 8px; text-align: left;'>{col}</th>"
|
111 |
+
html_output += "</tr>"
|
112 |
+
|
113 |
+
# Дані
|
114 |
+
for i, row in enumerate(preview_info['preview_rows']):
|
115 |
+
row_style = "background-color: #E9EDF5;" if i % 2 == 0 else ""
|
116 |
+
html_output += f"<tr style='{row_style}'>"
|
117 |
+
for col in preview_info['columns']:
|
118 |
+
value = row.get(col, "")
|
119 |
+
if isinstance(value, str) and len(value) > 100:
|
120 |
+
value = value[:100] + "..."
|
121 |
+
html_output += f"<td style='padding: 8px; border-bottom: 1px solid #ddd;'>{value}</td>"
|
122 |
+
html_output += "</tr>"
|
123 |
+
|
124 |
+
html_output += "</table>"
|
125 |
+
return html_output
|
126 |
+
|
127 |
+
except Exception as e:
|
128 |
+
logger.error(f"Помилка при отриманні попереднього перегляду файлу: {e}")
|
129 |
+
return f"<p style='color:red;'>Помилка при перегляді файлу: {str(e)}</p>"
|
130 |
+
|
131 |
+
def initialize_data(self, selected_files, uploaded_file=None, user_id=None):
|
132 |
+
try:
|
133 |
+
session_id = self.get_or_create_session(user_id)
|
134 |
+
self.app.current_session_id = session_id
|
135 |
+
|
136 |
+
local_files_info = self.data_manager.get_local_files()
|
137 |
+
local_files_dict = {info['name']: info['path'] for info in local_files_info}
|
138 |
+
|
139 |
+
selected_paths = []
|
140 |
+
for selected in selected_files:
|
141 |
+
file_name = selected.split(" (")[0].strip() if " (" in selected else selected.strip()
|
142 |
+
if file_name in local_files_dict:
|
143 |
+
selected_paths.append(local_files_dict[file_name])
|
144 |
+
|
145 |
+
uploaded_file_path = None
|
146 |
+
if uploaded_file:
|
147 |
+
if hasattr(uploaded_file, 'name'):
|
148 |
+
uploaded_file_path = uploaded_file.name
|
149 |
+
else:
|
150 |
+
uploaded_file_path = uploaded_file
|
151 |
+
|
152 |
+
if not selected_paths and not uploaded_file_path:
|
153 |
+
return "<p style='color:red;'>Помилка: не вибрано жодного файлу для обробки</p>", None
|
154 |
+
|
155 |
+
success, result_info = self.data_manager.initialize_session_data(
|
156 |
+
session_id,
|
157 |
+
selected_paths,
|
158 |
+
uploaded_file_path
|
159 |
+
)
|
160 |
+
if not success:
|
161 |
+
error_msg = result_info.get("error", "Невідома помилка")
|
162 |
+
return f"<p style='color:red;'>Помилка при ініціалізації даних: {error_msg}</p>", None
|
163 |
+
|
164 |
+
merged_df = result_info.get("merged_df")
|
165 |
+
if merged_df is not None:
|
166 |
+
self.app.current_data = merged_df
|
167 |
+
self.app.last_loaded_csv = result_info.get("merged_file")
|
168 |
+
|
169 |
+
indices_dir = self.session_manager.get_session_indices_dir(session_id)
|
170 |
+
if indices_dir:
|
171 |
+
abs_indices_path = os.path.abspath(indices_dir)
|
172 |
+
self.app.indices_path = abs_indices_path
|
173 |
+
logger.info(f"Встановлено шлях до директорії для індексів в app: {abs_indices_path}")
|
174 |
+
|
175 |
+
# Спроба зберегти шлях глобально
|
176 |
+
try:
|
177 |
+
import builtins
|
178 |
+
if hasattr(builtins, 'app'):
|
179 |
+
builtins.app.indices_path = self.app.indices_path
|
180 |
+
logger.info("Збережено шлях до директорії індексів у глобальному об'єкті app")
|
181 |
+
if hasattr(builtins, 'index_manager'):
|
182 |
+
builtins.index_manager.last_indices_path = self.app.indices_path
|
183 |
+
logger.info("Збережено шлях до директорії індексів у глобальному об'єкті index_manager")
|
184 |
+
except Exception as e:
|
185 |
+
logger.warning(f"Не вдалося зберегти шлях глобально: {e}")
|
186 |
+
|
187 |
+
status_html = "<h3 style='color:green;'>Дані успішно ініціалізовано</h3>"
|
188 |
+
status_html += f"<p>Об'єднано {result_info.get('source_files_count', 0)} файлів</p>"
|
189 |
+
status_html += f"<p>Загальна кількість рядків: {result_info.get('rows_count', 0)}</p>"
|
190 |
+
status_html += f"<p>Кількість колонок: {result_info.get('columns_count', 0)}</p>"
|
191 |
+
|
192 |
+
files_info = {
|
193 |
+
"session_id": session_id,
|
194 |
+
"merged_file": result_info.get("merged_file"),
|
195 |
+
"rows_count": result_info.get("rows_count", 0),
|
196 |
+
"columns_count": result_info.get("columns_count", 0),
|
197 |
+
"source_files_count": result_info.get("source_files_count", 0),
|
198 |
+
"indices_dir": indices_dir if indices_dir else None
|
199 |
+
}
|
200 |
+
|
201 |
+
return status_html, files_info
|
202 |
+
|
203 |
+
except Exception as e:
|
204 |
+
logger.error(f"Помилка при ініціалізації даних: {e}")
|
205 |
+
error_details = traceback.format_exc()
|
206 |
+
logger.error(error_details)
|
207 |
+
return f"<p style='color:red;'>Помилка при ініціалізації даних: {str(e)}</p>", None
|
modules/interface/visualizations_ui.py
ADDED
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import gradio as gr
|
2 |
+
import logging
|
3 |
+
from datetime import datetime
|
4 |
+
from pathlib import Path
|
5 |
+
import matplotlib.pyplot as plt
|
6 |
+
|
7 |
+
logger = logging.getLogger("jira_assistant_interface")
|
8 |
+
|
9 |
+
def on_viz_generate_clicked(viz_type, limit, groupby_text, app):
|
10 |
+
"""
|
11 |
+
Обробник для кнопки "Генерувати".
|
12 |
+
"""
|
13 |
+
groupby_map = {"день": "day", "тиждень": "week", "місяць": "month"}
|
14 |
+
groupby = groupby_map.get(groupby_text, "day")
|
15 |
+
|
16 |
+
if not hasattr(app, 'current_data') or app.current_data is None:
|
17 |
+
return gr.Plot.update(value=None), "Спочатку завантажте та проаналізуйте дані"
|
18 |
+
|
19 |
+
fig = app.generate_visualization(viz_type, limit=limit, groupby=groupby)
|
20 |
+
if fig:
|
21 |
+
return fig, None
|
22 |
+
else:
|
23 |
+
return None, f"Не вдалося згенерувати візуалізацію типу '{viz_type}'"
|
24 |
+
|
25 |
+
def save_visualization(viz_type, limit, groupby_text, filename, app):
|
26 |
+
"""
|
27 |
+
Зберігає згенеровану візуалізацію у файл.
|
28 |
+
"""
|
29 |
+
try:
|
30 |
+
groupby_map = {"день": "day", "тиждень": "week", "місяць": "month"}
|
31 |
+
groupby = groupby_map.get(groupby_text, "day")
|
32 |
+
|
33 |
+
fig = app.generate_visualization(viz_type, limit=limit, groupby=groupby)
|
34 |
+
if fig is None:
|
35 |
+
return "Помилка: не вдалося створити візуалізацію"
|
36 |
+
|
37 |
+
if not filename:
|
38 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
39 |
+
viz_type_clean = viz_type.lower().replace(' ', '_').replace(':', '_')
|
40 |
+
filename = f"viz_{viz_type_clean}_{timestamp}.png"
|
41 |
+
|
42 |
+
if not any(filename.lower().endswith(ext) for ext in ['.png', '.jpg', '.svg', '.pdf']):
|
43 |
+
filename += '.png'
|
44 |
+
|
45 |
+
reports_dir = Path("reports/visualizations")
|
46 |
+
reports_dir.mkdir(parents=True, exist_ok=True)
|
47 |
+
|
48 |
+
filepath = reports_dir / filename
|
49 |
+
fig.savefig(filepath, dpi=300, bbox_inches='tight')
|
50 |
+
plt.close(fig)
|
51 |
+
|
52 |
+
return f"✅ Візуалізацію збережено: {filepath}"
|
53 |
+
except Exception as e:
|
54 |
+
import traceback
|
55 |
+
error_msg = f"Помилка збереження візуалізації: {str(e)}\n\n{traceback.format_exc()}"
|
56 |
+
logger.error(error_msg)
|
57 |
+
return error_msg
|
58 |
+
|
59 |
+
def create_visualizations_tab(app):
|
60 |
+
"""
|
61 |
+
Створює вкладку 'Візуалізації' у Gradio інтерфейсі.
|
62 |
+
"""
|
63 |
+
with gr.Tab("Візуалізації"):
|
64 |
+
gr.Markdown("## Типи візуалізацій")
|
65 |
+
|
66 |
+
with gr.Row():
|
67 |
+
viz_type = gr.Dropdown(
|
68 |
+
choices=[
|
69 |
+
"Статуси", "Пріоритети", "Типи тікетів", "Призначені користувачі",
|
70 |
+
"Активність створення", "Активність оновлення", "Кумулятивне створення",
|
71 |
+
"Неактивні тікети", "Теплова карта: Типи/Статуси",
|
72 |
+
"Часова шкала проекту", "Склад статусів з часом"
|
73 |
+
],
|
74 |
+
value="Статуси",
|
75 |
+
label="Тип візуалізації"
|
76 |
+
)
|
77 |
+
viz_generate_btn = gr.Button("Генерувати", variant="primary")
|
78 |
+
|
79 |
+
with gr.Accordion("Параметри візуалізації", open=False):
|
80 |
+
with gr.Row():
|
81 |
+
viz_param_limit = gr.Slider(minimum=5, maximum=20, value=10, step=1,
|
82 |
+
label="Ліміт для топ-візуалізацій")
|
83 |
+
viz_param_groupby = gr.Dropdown(
|
84 |
+
choices=["день", "тиждень", "місяць"],
|
85 |
+
value="день",
|
86 |
+
label="Групування для часових діаграм"
|
87 |
+
)
|
88 |
+
|
89 |
+
with gr.Row():
|
90 |
+
viz_plot = gr.Plot(label="Візуалізація")
|
91 |
+
viz_status = gr.Textbox(label="Статус", visible=False)
|
92 |
+
|
93 |
+
with gr.Row():
|
94 |
+
viz_filename = gr.Textbox(
|
95 |
+
label="Ім'я файлу (опціонально)",
|
96 |
+
placeholder="Залиште порожнім для автоматичного імені"
|
97 |
+
)
|
98 |
+
viz_save_btn = gr.Button("Зберегти візуалізацію", variant="secondary")
|
99 |
+
viz_save_status = gr.Textbox(label="Статус збереження")
|
100 |
+
|
101 |
+
# Прив'язка подій
|
102 |
+
viz_generate_btn.click(
|
103 |
+
lambda t, l, g: on_viz_generate_clicked(t, l, g, app),
|
104 |
+
inputs=[viz_type, viz_param_limit, viz_param_groupby],
|
105 |
+
outputs=[viz_plot, viz_status]
|
106 |
+
)
|
107 |
+
|
108 |
+
viz_save_btn.click(
|
109 |
+
lambda t, l, g, f: save_visualization(t, l, g, f, app),
|
110 |
+
inputs=[viz_type, viz_param_limit, viz_param_groupby, viz_filename],
|
111 |
+
outputs=[viz_save_status]
|
112 |
+
)
|
modules/reporting/report_generator.py
ADDED
@@ -0,0 +1,374 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import logging
|
3 |
+
import pandas as pd
|
4 |
+
import re
|
5 |
+
from datetime import datetime
|
6 |
+
from pathlib import Path
|
7 |
+
import markdown
|
8 |
+
import matplotlib.pyplot as plt
|
9 |
+
import base64
|
10 |
+
from io import BytesIO
|
11 |
+
|
12 |
+
from modules.data_management.data_manager import safe_strftime
|
13 |
+
|
14 |
+
logger = logging.getLogger(__name__)
|
15 |
+
|
16 |
+
class ReportGenerator:
|
17 |
+
"""
|
18 |
+
Клас для генерації звітів на основі аналізу даних Jira
|
19 |
+
"""
|
20 |
+
def __init__(self, df, stats=None, inactive_issues=None, ai_analysis=None):
|
21 |
+
"""
|
22 |
+
Ініціалізація генератора звітів.
|
23 |
+
|
24 |
+
Args:
|
25 |
+
df (pandas.DataFrame): DataFrame з даними Jira
|
26 |
+
stats (dict): Словник зі статистикою (або None)
|
27 |
+
inactive_issues (dict): Дані про неактивні тікети (або None)
|
28 |
+
ai_analysis (str): Текст AI аналізу (або None)
|
29 |
+
"""
|
30 |
+
self.df = df
|
31 |
+
self.stats = stats
|
32 |
+
self.inactive_issues = inactive_issues
|
33 |
+
self.ai_analysis = ai_analysis
|
34 |
+
|
35 |
+
def create_markdown_report(self, inactive_days=14):
|
36 |
+
"""
|
37 |
+
Створення звіту у форматі Markdown.
|
38 |
+
|
39 |
+
Args:
|
40 |
+
inactive_days (int): Кількість днів для визначення неактивних тікетів
|
41 |
+
|
42 |
+
Returns:
|
43 |
+
str: Текст звіту у форматі Markdown
|
44 |
+
"""
|
45 |
+
try:
|
46 |
+
report = []
|
47 |
+
|
48 |
+
# Заголовок звіту
|
49 |
+
report.append("# Звіт аналізу Jira")
|
50 |
+
# report.append(f"*Створено: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*")
|
51 |
+
report.append(f'*Створено: {safe_strftime(datetime.now(), "%Y-%m-%d")}*')
|
52 |
+
|
53 |
+
# Загальна статистика
|
54 |
+
report.append("\n## Загальна статистика")
|
55 |
+
|
56 |
+
if self.stats and 'total_tickets' in self.stats:
|
57 |
+
report.append(f"**Загальна кількість тікетів:** {self.stats['total_tickets']}")
|
58 |
+
else:
|
59 |
+
report.append(f"**Загальна кількість тікетів:** {len(self.df)}")
|
60 |
+
|
61 |
+
# Статистика за статусами
|
62 |
+
if self.stats and 'status_counts' in self.stats and self.stats['status_counts']:
|
63 |
+
report.append("\n### Статуси тікетів")
|
64 |
+
|
65 |
+
for status, count in self.stats['status_counts'].items():
|
66 |
+
percentage = count / self.stats['total_tickets'] * 100 if self.stats['total_tickets'] > 0 else 0
|
67 |
+
report.append(f"- **{status}:** {count} ({percentage:.1f}%)")
|
68 |
+
elif 'Status' in self.df.columns:
|
69 |
+
status_counts = self.df['Status'].value_counts()
|
70 |
+
report.append("\n### Статуси тікетів")
|
71 |
+
|
72 |
+
for status, count in status_counts.items():
|
73 |
+
percentage = count / len(self.df) * 100 if len(self.df) > 0 else 0
|
74 |
+
report.append(f"- **{status}:** {count} ({percentage:.1f}%)")
|
75 |
+
|
76 |
+
# Статистика за типами
|
77 |
+
if self.stats and 'type_counts' in self.stats and self.stats['type_counts']:
|
78 |
+
report.append("\n### Типи тікетів")
|
79 |
+
|
80 |
+
for type_name, count in self.stats['type_counts'].items():
|
81 |
+
percentage = count / self.stats['total_tickets'] * 100 if self.stats['total_tickets'] > 0 else 0
|
82 |
+
report.append(f"- **{type_name}:** {count} ({percentage:.1f}%)")
|
83 |
+
elif 'Issue Type' in self.df.columns:
|
84 |
+
type_counts = self.df['Issue Type'].value_counts()
|
85 |
+
report.append("\n### Типи тікетів")
|
86 |
+
|
87 |
+
for type_name, count in type_counts.items():
|
88 |
+
percentage = count / len(self.df) * 100 if len(self.df) > 0 else 0
|
89 |
+
report.append(f"- **{type_name}:** {count} ({percentage:.1f}%)")
|
90 |
+
|
91 |
+
# Статистика за пріоритетами
|
92 |
+
if self.stats and 'priority_counts' in self.stats and self.stats['priority_counts']:
|
93 |
+
report.append("\n### Пріоритети тікетів")
|
94 |
+
|
95 |
+
for priority, count in self.stats['priority_counts'].items():
|
96 |
+
percentage = count / self.stats['total_tickets'] * 100 if self.stats['total_tickets'] > 0 else 0
|
97 |
+
report.append(f"- **{priority}:** {count} ({percentage:.1f}%)")
|
98 |
+
elif 'Priority' in self.df.columns:
|
99 |
+
priority_counts = self.df['Priority'].value_counts()
|
100 |
+
report.append("\n### Пріоритети тікетів")
|
101 |
+
|
102 |
+
for priority, count in priority_counts.items():
|
103 |
+
percentage = count / len(self.df) * 100 if len(self.df) > 0 else 0
|
104 |
+
report.append(f"- **{priority}:** {count} ({percentage:.1f}%)")
|
105 |
+
|
106 |
+
# Аналіз часових показників
|
107 |
+
if 'Created' in self.df.columns and pd.api.types.is_datetime64_dtype(self.df['Created']):
|
108 |
+
report.append("\n## Часові показники")
|
109 |
+
|
110 |
+
min_date = self.df['Created'].min()
|
111 |
+
max_date = self.df['Created'].max()
|
112 |
+
|
113 |
+
# report.append(f"**Період створення тікетів:** з {min_date.strftime('%Y-%m-%d')} по {max_date.strftime('%Y-%m-%d')}")
|
114 |
+
report.append(f'**Період створення тікетів:** з {safe_strftime(min_date, "%Y-%m-%d")} по {safe_strftime(max_date, "%Y-%m-%d")}')
|
115 |
+
|
116 |
+
# Тікети за останній тиждень
|
117 |
+
last_week = (datetime.now() - pd.Timedelta(days=7))
|
118 |
+
recent_tickets = self.df[self.df['Created'] >= last_week]
|
119 |
+
report.append(f"**Тікети, створені за останній тиждень:** {len(recent_tickets)}")
|
120 |
+
|
121 |
+
# Неактивні тікети
|
122 |
+
if self.inactive_issues:
|
123 |
+
report.append(f"\n## Неактивні тікети (>{inactive_days} днів)")
|
124 |
+
|
125 |
+
total_inactive = self.inactive_issues.get('total_count', 0)
|
126 |
+
percentage = self.inactive_issues.get('percentage', 0)
|
127 |
+
|
128 |
+
report.append(f"**Загальна кількість неактивних тікетів:** {total_inactive} ({percentage:.1f}%)")
|
129 |
+
|
130 |
+
if 'by_status' in self.inactive_issues and self.inactive_issues['by_status']:
|
131 |
+
report.append("\n**Неактивні тікети за статусами:**")
|
132 |
+
|
133 |
+
for status, count in self.inactive_issues['by_status'].items():
|
134 |
+
report.append(f"- **{status}:** {count}")
|
135 |
+
|
136 |
+
if 'top_inactive' in self.inactive_issues and self.inactive_issues['top_inactive']:
|
137 |
+
report.append("\n**Топ 5 найбільш неактивних тікетів:**")
|
138 |
+
|
139 |
+
for i, ticket in enumerate(self.inactive_issues['top_inactive']):
|
140 |
+
key = ticket.get('key', 'Невідомо')
|
141 |
+
summary = ticket.get('summary', 'Невідомо')
|
142 |
+
status = ticket.get('status', 'Невідомо')
|
143 |
+
days = ticket.get('days_inactive', 'Невідомо')
|
144 |
+
|
145 |
+
report.append(f"{i+1}. **{key}:** {summary}")
|
146 |
+
report.append(f" - Статус: {status}")
|
147 |
+
report.append(f" - Днів неактивності: {days}")
|
148 |
+
|
149 |
+
# AI Аналіз
|
150 |
+
if self.ai_analysis:
|
151 |
+
report.append("\n## AI Аналіз")
|
152 |
+
report.append(self.ai_analysis)
|
153 |
+
|
154 |
+
logger.info("Звіт успішно згенеровано у форматі Markdown")
|
155 |
+
return "\n".join(report)
|
156 |
+
|
157 |
+
except Exception as e:
|
158 |
+
logger.error(f"Помилка при створенні звіту: {e}")
|
159 |
+
return f"Помилка при створенні звіту: {str(e)}"
|
160 |
+
|
161 |
+
def create_html_report(self, inactive_days=14, include_visualizations=False, visualization_data=None):
|
162 |
+
"""
|
163 |
+
Створення звіту у форматі HTML.
|
164 |
+
|
165 |
+
Args:
|
166 |
+
inactive_days (int): Кількість днів для визначення неактивних тікетів
|
167 |
+
include_visualizations (bool): Чи включати візуалізації у звіт
|
168 |
+
visualization_data (dict): Словник з об'єктами Figure для візуалізацій
|
169 |
+
|
170 |
+
Returns:
|
171 |
+
str: Текст звіту у форматі HTML
|
172 |
+
"""
|
173 |
+
try:
|
174 |
+
# Спочатку створюємо звіт у форматі Markdown
|
175 |
+
md_report = self.create_markdown_report(inactive_days)
|
176 |
+
|
177 |
+
# Конвертуємо Markdown у HTML
|
178 |
+
html_report = self.convert_markdown_to_html(md_report)
|
179 |
+
|
180 |
+
# Додаємо візуалізації, якщо потрібно
|
181 |
+
if include_visualizations and visualization_data:
|
182 |
+
html_with_charts = self._add_visualizations_to_html(html_report, visualization_data)
|
183 |
+
return html_with_charts
|
184 |
+
|
185 |
+
return html_report
|
186 |
+
|
187 |
+
except Exception as e:
|
188 |
+
logger.error(f"Помилка при створенні HTML звіту: {e}")
|
189 |
+
return f"<h1>Помилка при створенні звіту</h1><p>{str(e)}</p>"
|
190 |
+
|
191 |
+
def convert_markdown_to_html(self, md_text):
|
192 |
+
"""
|
193 |
+
Конвертація тексту з формату Markdown у HTML.
|
194 |
+
|
195 |
+
Args:
|
196 |
+
md_text (str): Текст у форматі Markdown
|
197 |
+
|
198 |
+
Returns:
|
199 |
+
str: Текст у форматі HTML
|
200 |
+
"""
|
201 |
+
try:
|
202 |
+
# Додаємо CSS стилі
|
203 |
+
css = """
|
204 |
+
<style>
|
205 |
+
body { font-family: Arial, sans-serif; line-height: 1.6; margin: 20px; max-width: 1200px; margin: 0 auto; }
|
206 |
+
h1, h2, h3 { color: #0052CC; }
|
207 |
+
table { border-collapse: collapse; width: 100%; margin-bottom: 20px; }
|
208 |
+
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }
|
209 |
+
th { background-color: #0052CC; color: white; }
|
210 |
+
tr:hover { background-color: #f5f5f5; }
|
211 |
+
.progress-container { width: 100%; background-color: #f1f1f1; border-radius: 3px; }
|
212 |
+
.progress-bar { height: 20px; border-radius: 3px; }
|
213 |
+
img { max-width: 100%; }
|
214 |
+
</style>
|
215 |
+
"""
|
216 |
+
|
217 |
+
# Конвертація Markdown в HTML
|
218 |
+
html_content = markdown.markdown(md_text, extensions=['tables', 'fenced_code'])
|
219 |
+
|
220 |
+
# Складаємо повний HTML документ
|
221 |
+
html = f"""<!DOCTYPE html>
|
222 |
+
<html lang="uk">
|
223 |
+
<head>
|
224 |
+
<meta charset="UTF-8">
|
225 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
226 |
+
<title>Звіт аналізу Jira</title>
|
227 |
+
{css}
|
228 |
+
</head>
|
229 |
+
<body>
|
230 |
+
{html_content}
|
231 |
+
</body>
|
232 |
+
</html>
|
233 |
+
"""
|
234 |
+
|
235 |
+
return html
|
236 |
+
|
237 |
+
except Exception as e:
|
238 |
+
logger.error(f"Помилка при конвертації Markdown в HTML: {e}")
|
239 |
+
return f"<h1>Помилка при конвертації звіту</h1><p>{str(e)}</p>"
|
240 |
+
|
241 |
+
def _add_visualizations_to_html(self, html_content, visualization_data):
|
242 |
+
"""
|
243 |
+
Додавання візуалізацій до HTML звіту.
|
244 |
+
|
245 |
+
Args:
|
246 |
+
html_content (str): Текст HTML звіту
|
247 |
+
visualization_data (dict): Словник з об'єктами Figure для візуалізацій
|
248 |
+
|
249 |
+
Returns:
|
250 |
+
str: HTML звіт з візуалізаціями
|
251 |
+
"""
|
252 |
+
try:
|
253 |
+
# Додаємо розділ з візуалізаціями перед закриваючим тегом body
|
254 |
+
charts_html = "<h2>Візуалізації</h2>"
|
255 |
+
|
256 |
+
# Конвертуємо кожну візуалізацію у base64 та додаємо до HTML
|
257 |
+
for name, fig in visualization_data.items():
|
258 |
+
if fig:
|
259 |
+
# Зберігаємо фігуру в байтовий потік
|
260 |
+
buf = BytesIO()
|
261 |
+
fig.savefig(buf, format='png', dpi=100)
|
262 |
+
buf.seek(0)
|
263 |
+
|
264 |
+
# Конвертуємо в base64
|
265 |
+
img_str = base64.b64encode(buf.read()).decode('utf-8')
|
266 |
+
|
267 |
+
# Додаємо зображення до HTML
|
268 |
+
title_map = {
|
269 |
+
'status': 'Статуси тікетів',
|
270 |
+
'priority': 'Пріоритети тікетів',
|
271 |
+
'type': 'Типи тікетів',
|
272 |
+
'created_timeline': 'Часова шкала створення тікетів',
|
273 |
+
'inactive': 'Неактивні тікети',
|
274 |
+
'status_timeline': 'Зміна статусів з часом',
|
275 |
+
'lead_time': 'Час виконання тікетів за типами'
|
276 |
+
}
|
277 |
+
|
278 |
+
title = title_map.get(name, name.replace('_', ' ').title())
|
279 |
+
|
280 |
+
charts_html += f"""
|
281 |
+
<div style="text-align: center; margin-bottom: 30px;">
|
282 |
+
<h3>{title}</h3>
|
283 |
+
<img src="data:image/png;base64,{img_str}" alt="{title}" style="max-width: 100%;">
|
284 |
+
</div>
|
285 |
+
"""
|
286 |
+
|
287 |
+
# Вставляємо візуалізації перед закриваючим тегом body
|
288 |
+
html_with_charts = html_content.replace("</body>", f"{charts_html}</body>")
|
289 |
+
|
290 |
+
return html_with_charts
|
291 |
+
|
292 |
+
except Exception as e:
|
293 |
+
logger.error(f"Помилка при додаванні візуалізацій до HTML: {e}")
|
294 |
+
return html_content
|
295 |
+
|
296 |
+
def save_report(self, filepath, format='markdown', include_visualizations=False, visualization_data=None):
|
297 |
+
"""
|
298 |
+
Збереження звіту у файл.
|
299 |
+
|
300 |
+
Args:
|
301 |
+
filepath (str): Шлях до файлу для збереження
|
302 |
+
format (str): Формат звіту ('markdown', 'html', 'pdf')
|
303 |
+
include_visualizations (bool): Чи включати візуалізації у звіт
|
304 |
+
visualization_data (dict): Словник з об'єктами Figure для візуалізацій
|
305 |
+
|
306 |
+
Returns:
|
307 |
+
str: Шлях до збереженого файлу або None у випадку помилки
|
308 |
+
"""
|
309 |
+
try:
|
310 |
+
# Створення директорії для файлу, якщо вона не існує
|
311 |
+
directory = os.path.dirname(filepath)
|
312 |
+
if directory and not os.path.exists(directory):
|
313 |
+
os.makedirs(directory)
|
314 |
+
|
315 |
+
# Вибір формату та створення звіту
|
316 |
+
if format.lower() == 'markdown':
|
317 |
+
report_text = self.create_markdown_report()
|
318 |
+
|
319 |
+
# Перевірка розширення файлу
|
320 |
+
if not filepath.lower().endswith('.md'):
|
321 |
+
filepath += '.md'
|
322 |
+
|
323 |
+
# Збереження у файл
|
324 |
+
with open(filepath, 'w', encoding='utf-8') as f:
|
325 |
+
f.write(report_text)
|
326 |
+
|
327 |
+
elif format.lower() == 'html':
|
328 |
+
html_report = self.create_html_report(include_visualizations=include_visualizations,
|
329 |
+
visualization_data=visualization_data)
|
330 |
+
|
331 |
+
# Перевірка розширення файлу
|
332 |
+
if not filepath.lower().endswith('.html'):
|
333 |
+
filepath += '.html'
|
334 |
+
|
335 |
+
# Збереження у файл
|
336 |
+
with open(filepath, 'w', encoding='utf-8') as f:
|
337 |
+
f.write(html_report)
|
338 |
+
|
339 |
+
elif format.lower() == 'pdf':
|
340 |
+
# Створення спочатку HTML
|
341 |
+
html_report = self.create_html_report(include_visualizations=include_visualizations,
|
342 |
+
visualization_data=visualization_data)
|
343 |
+
|
344 |
+
# Перевірка розширення файлу
|
345 |
+
if not filepath.lower().endswith('.pdf'):
|
346 |
+
filepath += '.pdf'
|
347 |
+
|
348 |
+
# Створення тимчасового HTML-файлу
|
349 |
+
temp_html_path = filepath + "_temp.html"
|
350 |
+
with open(temp_html_path, 'w', encoding='utf-8') as f:
|
351 |
+
f.write(html_report)
|
352 |
+
|
353 |
+
try:
|
354 |
+
# Конвертація HTML в PDF
|
355 |
+
from weasyprint import HTML
|
356 |
+
HTML(filename=temp_html_path).write_pdf(filepath)
|
357 |
+
|
358 |
+
# Видалення тимчасового HTML-файлу
|
359 |
+
if os.path.exists(temp_html_path):
|
360 |
+
os.remove(temp_html_path)
|
361 |
+
|
362 |
+
except Exception as e:
|
363 |
+
logger.error(f"Помилка при конвертації в PDF: {e}")
|
364 |
+
return None
|
365 |
+
else:
|
366 |
+
logger.error(f"Непідтримуваний формат звіту: {format}")
|
367 |
+
return None
|
368 |
+
|
369 |
+
logger.info(f"Звіт успішно збережено у файл: {filepath}")
|
370 |
+
return filepath
|
371 |
+
|
372 |
+
except Exception as e:
|
373 |
+
logger.error(f"Помилка при збереженні звіту: {e}")
|
374 |
+
return None
|
prompts.py
ADDED
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Стандартні промпти для різних режимів
|
2 |
+
system_prompt_qa_assistant = """Ти асистент з аналізу даних Jira.
|
3 |
+
Тобі подається повний контекст з усіма тікетами проекту.
|
4 |
+
Використовуй усі доступні метадані та текстові дані для відповіді на питання.
|
5 |
+
Відповідай українською мовою, якщо не вказано інше.
|
6 |
+
При посиланні на таск (цитуванні) обов'язково використовуй формат лінки https://jira.healthprecision.net/browse/IEE-[номеp]
|
7 |
+
|
8 |
+
"""
|
9 |
+
|
10 |
+
|
11 |
+
system_prompt_hybrid_chat = """Ви - AI асистент для аналізу даних Jira.
|
12 |
+
Ваше завдання - допомагати користувачам аналізувати дані з Jira та відповідати на їхні запитання.
|
13 |
+
Використовуйте надані документи як контекст для відповідей.
|
14 |
+
Відповідайте українською мовою, якщо не вказано інше.
|
15 |
+
Будьте точними, інформативними та корисними."""
|
16 |
+
|
17 |
+
|
18 |
+
def get_report_prompt(format_type):
|
19 |
+
"""
|
20 |
+
Повертає системний промпт для генерації найінформативнішого звіту з Jira.
|
21 |
+
|
22 |
+
Функція генерує покращений системний промпт, що допомагає створити глибокий аналітичний звіт для
|
23 |
+
проджект-менеджерів та тім-лідів. Звіт містить ключові аспекти стану проекту, проблеми, ризики,
|
24 |
+
рекомендації та висновки.
|
25 |
+
|
26 |
+
Args:
|
27 |
+
format_type (str): Тип формату, наприклад "markdown" або "html".
|
28 |
+
|
29 |
+
Returns:
|
30 |
+
str: Розширений системний промпт з інструкціями форматування.
|
31 |
+
"""
|
32 |
+
# Встановлюємо базові інструкції щодо формату
|
33 |
+
format_instruction = ""
|
34 |
+
if format_type.lower() == "markdown":
|
35 |
+
format_instruction = (
|
36 |
+
"Використовуйте розмітку Markdown (заголовки, списки, таблиці) для візуальної структури. "
|
37 |
+
"Додавайте підзаголовки для ключових секцій, використовуйте списки для відображення ризиків "
|
38 |
+
"та рекомендацій, а також зверніть увагу на чітке відокремлення розділів за допомогою заголовків різного рівня."
|
39 |
+
)
|
40 |
+
elif format_type.lower() == "html":
|
41 |
+
format_instruction = (
|
42 |
+
"Створіть структурований звіт з використанням тегів <h1>, <h2> тощо для заголовків, "
|
43 |
+
"<ul>/<ol> для списків, <table> для табличних даних, та розташовуйте ключові частини звіту "
|
44 |
+
"по розділах, що легко читати."
|
45 |
+
)
|
46 |
+
|
47 |
+
# Створюємо базовий текст промпту
|
48 |
+
prompt_template = """Ви досвідчений аналітик даних з Jira.
|
49 |
+
Вам надано докладні дані про проект для аналізу та створення професійного, глибокого звіту.
|
50 |
+
|
51 |
+
Сформуйте детальний звіт, який містить такі розділи:
|
52 |
+
1. Короткий огляд проекту (ключова мета, тривалість, основні учасники).
|
53 |
+
2. Аналіз поточного стану:
|
54 |
+
- Статус відкритих, закритих та в роботі тікетів (підсумок та тренди).
|
55 |
+
- Розподіл тікетів за типами (Bug, Task, Story, Sub-task, тощо).
|
56 |
+
- Пріоритети та їх розподіл.
|
57 |
+
- Середній час до вирішення (якщо доступно).
|
58 |
+
3. Виявлені проблеми та ризики:
|
59 |
+
- Потенційно заблоковані або прострочені тікети.
|
60 |
+
- Можливі конфлікти у пріоритизації.
|
61 |
+
- Зони ризику, що впливають на загальний план.
|
62 |
+
4. Рекомендації для покращення процесу:
|
63 |
+
- Пропозиції щодо оптимізації робочих процесів, планування спринтів та ресурсів.
|
64 |
+
- Ідеї для покращення комунікації між командами.
|
65 |
+
- Шляхи зниження ризиків та покращення якості коду.
|
66 |
+
5. Висновки:
|
67 |
+
- Підсумок ключових моментів та наступні кроки.
|
68 |
+
|
69 |
+
"""
|
70 |
+
|
71 |
+
prompt_template += format_instruction + "\n\n"
|
72 |
+
prompt_template += (
|
73 |
+
"Звіт повинен бути максимально конкретним, з реальними метриками або доказами, "
|
74 |
+
"де це можливо, і орієнтованим на подальші дії. Використовуйте українську мову.\n"
|
75 |
+
)
|
76 |
+
|
77 |
+
return prompt_template
|
78 |
+
|
79 |
+
|
80 |
+
|
81 |
+
# def get_report_prompt(format_type):
|
82 |
+
# """
|
83 |
+
# Повертає системний промпт для генерації звіту з відповідним форматуванням.
|
84 |
+
|
85 |
+
# Args:
|
86 |
+
# format_type (str): Тип формату ("markdown" або "html")
|
87 |
+
|
88 |
+
# Returns:
|
89 |
+
# str: Системний промпт з інструкціями форматування
|
90 |
+
# """
|
91 |
+
# format_instruction = ""
|
92 |
+
# if format_type.lower() == "markdown":
|
93 |
+
# format_instruction = "Використовуйте Markdown для форматування звіту."
|
94 |
+
# elif format_type.lower() == "html":
|
95 |
+
# format_instruction = "Створіть звіт у форматі HTML з використанням відповідних тегів."
|
96 |
+
|
97 |
+
# return f"""Ви досвідчений аналітик даних з Jira.
|
98 |
+
# Вам надано дані про проект для аналізу та створення професійного звіту.
|
99 |
+
|
100 |
+
# Створіть структурований звіт з такими розділами:
|
101 |
+
# 1. Короткий огляд проекту
|
102 |
+
# 2. Аналіз поточного стану (статус тікетів, розподіл за типами та пріоритетами)
|
103 |
+
# 3. Виявлені проблеми та ризики
|
104 |
+
# 4. Рекомендації для покращення процесу
|
105 |
+
# 5. Висновки
|
106 |
+
|
107 |
+
# {format_instruction}
|
108 |
+
|
109 |
+
# Звіт повинен бути інформативним, конкретним та орієнтованим на дії.
|
110 |
+
# Використовуйте українську мову.
|
111 |
+
# """
|
requirements.txt
ADDED
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
accelerate==1.4.0
|
2 |
+
aiofiles==23.2.1
|
3 |
+
aiohappyeyeballs==2.4.8
|
4 |
+
aiohttp==3.11.13
|
5 |
+
aiosignal==1.3.2
|
6 |
+
annotated-types==0.7.0
|
7 |
+
anyio==4.8.0
|
8 |
+
async-timeout==5.0.1
|
9 |
+
attrs==25.1.0
|
10 |
+
beautifulsoup4==4.13.3
|
11 |
+
bm25s==0.2.7.post1
|
12 |
+
cachetools==5.5.2
|
13 |
+
certifi==2025.1.31
|
14 |
+
chardet==5.2.0
|
15 |
+
charset-normalizer==3.4.1
|
16 |
+
click==8.1.8
|
17 |
+
contourpy==1.3.1
|
18 |
+
cycler==0.12.1
|
19 |
+
dataclasses-json==0.6.7
|
20 |
+
defusedxml==0.7.1
|
21 |
+
Deprecated==1.2.18
|
22 |
+
dirtyjson==1.0.8
|
23 |
+
distro==1.9.0
|
24 |
+
et_xmlfile==2.0.0
|
25 |
+
exceptiongroup==1.2.2
|
26 |
+
faiss-cpu==1.7.4
|
27 |
+
fastapi==0.115.11
|
28 |
+
ffmpy==0.5.0
|
29 |
+
filelock==3.17.0
|
30 |
+
filetype==1.2.0
|
31 |
+
fonttools==4.56.0
|
32 |
+
frozenlist==1.5.0
|
33 |
+
fsspec==2025.2.0
|
34 |
+
google-ai-generativelanguage==0.6.15
|
35 |
+
google-api-core==2.24.1
|
36 |
+
google-api-python-client==2.162.0
|
37 |
+
google-auth==2.38.0
|
38 |
+
google-auth-httplib2==0.2.0
|
39 |
+
google-genai==1.3.0
|
40 |
+
google-generativeai==0.8.4
|
41 |
+
googleapis-common-protos==1.69.0
|
42 |
+
gradio==5.20.0
|
43 |
+
gradio_client==1.7.2
|
44 |
+
greenlet==3.1.1
|
45 |
+
groovy==0.1.2
|
46 |
+
grpcio==1.70.0
|
47 |
+
grpcio-status==1.70.0
|
48 |
+
h11==0.14.0
|
49 |
+
httpcore==1.0.7
|
50 |
+
httplib2==0.22.0
|
51 |
+
httpx==0.28.1
|
52 |
+
huggingface-hub==0.29.1
|
53 |
+
idna==3.10
|
54 |
+
Jinja2==3.1.5
|
55 |
+
jira==3.8.0
|
56 |
+
jiter==0.8.2
|
57 |
+
joblib==1.4.2
|
58 |
+
kiwisolver==1.4.8
|
59 |
+
llama-cloud==0.1.13
|
60 |
+
llama-cloud-services==0.6.3
|
61 |
+
llama-index==0.12.22
|
62 |
+
llama-index-agent-openai==0.4.6
|
63 |
+
llama-index-cli==0.4.1
|
64 |
+
llama-index-core==0.12.22
|
65 |
+
llama-index-embeddings-gemini==0.3.2
|
66 |
+
llama-index-embeddings-huggingface==0.5.2
|
67 |
+
llama-index-embeddings-openai==0.3.1
|
68 |
+
llama-index-indices-managed-llama-cloud==0.6.8
|
69 |
+
llama-index-llms-gemini==0.4.11
|
70 |
+
llama-index-llms-openai==0.3.25
|
71 |
+
llama-index-multi-modal-llms-openai==0.4.3
|
72 |
+
llama-index-program-openai==0.3.1
|
73 |
+
llama-index-question-gen-openai==0.3.0
|
74 |
+
llama-index-readers-file==0.4.6
|
75 |
+
llama-index-readers-llama-parse==0.4.0
|
76 |
+
llama-index-retrievers-bm25==0.5.2
|
77 |
+
llama-index-vector-stores-faiss==0.3.0
|
78 |
+
llama-parse==0.6.2
|
79 |
+
Markdown==3.7
|
80 |
+
markdown-it-py==3.0.0
|
81 |
+
MarkupSafe==2.1.5
|
82 |
+
marshmallow==3.26.1
|
83 |
+
matplotlib==3.7.5
|
84 |
+
mdurl==0.1.2
|
85 |
+
mpmath==1.3.0
|
86 |
+
multidict==6.1.0
|
87 |
+
mypy-extensions==1.0.0
|
88 |
+
narwhals==1.29.1
|
89 |
+
nest-asyncio==1.6.0
|
90 |
+
networkx==3.4.2
|
91 |
+
nltk==3.9.1
|
92 |
+
numpy==1.26.4
|
93 |
+
nvidia-cublas-cu12==12.4.5.8
|
94 |
+
nvidia-cuda-cupti-cu12==12.4.127
|
95 |
+
nvidia-cuda-nvrtc-cu12==12.4.127
|
96 |
+
nvidia-cuda-runtime-cu12==12.4.127
|
97 |
+
nvidia-cudnn-cu12==9.1.0.70
|
98 |
+
nvidia-cufft-cu12==11.2.1.3
|
99 |
+
nvidia-curand-cu12==10.3.5.147
|
100 |
+
nvidia-cusolver-cu12==11.6.1.9
|
101 |
+
nvidia-cusparse-cu12==12.3.1.170
|
102 |
+
nvidia-cusparselt-cu12==0.6.2
|
103 |
+
nvidia-nccl-cu12==2.21.5
|
104 |
+
nvidia-nvjitlink-cu12==12.4.127
|
105 |
+
nvidia-nvtx-cu12==12.4.127
|
106 |
+
oauthlib==3.2.2
|
107 |
+
openai==1.65.2
|
108 |
+
openpyxl==3.1.5
|
109 |
+
orjson==3.10.15
|
110 |
+
packaging==24.2
|
111 |
+
pandas==2.1.4
|
112 |
+
pathlib==1.0.1
|
113 |
+
pillow==10.4.0
|
114 |
+
plotly==6.0.0
|
115 |
+
propcache==0.3.0
|
116 |
+
proto-plus==1.26.0
|
117 |
+
protobuf==5.29.3
|
118 |
+
psutil==7.0.0
|
119 |
+
pyasn1==0.6.1
|
120 |
+
pyasn1_modules==0.4.1
|
121 |
+
pydantic==2.10.6
|
122 |
+
pydantic_core==2.27.2
|
123 |
+
pydub==0.25.1
|
124 |
+
Pygments==2.19.1
|
125 |
+
pyparsing==3.2.1
|
126 |
+
pypdf==5.3.1
|
127 |
+
PyStemmer==2.2.0.3
|
128 |
+
python-dateutil==2.9.0.post0
|
129 |
+
python-dotenv==1.0.1
|
130 |
+
python-multipart==0.0.20
|
131 |
+
pytz==2025.1
|
132 |
+
PyYAML==6.0.2
|
133 |
+
regex==2024.11.6
|
134 |
+
requests==2.32.3
|
135 |
+
requests-oauthlib==2.0.0
|
136 |
+
requests-toolbelt==1.0.0
|
137 |
+
rich==13.9.4
|
138 |
+
rsa==4.9
|
139 |
+
ruff==0.9.9
|
140 |
+
safehttpx==0.1.6
|
141 |
+
safetensors==0.5.3
|
142 |
+
scikit-learn==1.6.1
|
143 |
+
scipy==1.15.2
|
144 |
+
seaborn==0.12.2
|
145 |
+
semantic-version==2.10.0
|
146 |
+
sentence-transformers==3.4.1
|
147 |
+
shellingham==1.5.4
|
148 |
+
six==1.17.0
|
149 |
+
sniffio==1.3.1
|
150 |
+
soupsieve==2.6
|
151 |
+
SQLAlchemy==2.0.38
|
152 |
+
starlette==0.46.0
|
153 |
+
striprtf==0.0.26
|
154 |
+
sympy==1.13.1
|
155 |
+
tenacity==9.0.0
|
156 |
+
threadpoolctl==3.5.0
|
157 |
+
tiktoken==0.9.0
|
158 |
+
tokenizers==0.21.0
|
159 |
+
tomlkit==0.13.2
|
160 |
+
torch==2.6.0
|
161 |
+
tqdm==4.67.1
|
162 |
+
transformers==4.49.0
|
163 |
+
triton==3.2.0
|
164 |
+
typer==0.15.2
|
165 |
+
typing-inspect==0.9.0
|
166 |
+
typing_extensions==4.12.2
|
167 |
+
tzdata==2025.1
|
168 |
+
uritemplate==4.1.1
|
169 |
+
urllib3==2.3.0
|
170 |
+
uvicorn==0.34.0
|
171 |
+
websockets==14.2
|
172 |
+
wrapt==1.17.2
|
173 |
+
yarl==1.18.3
|