DocUA commited on
Commit
4ad5efa
·
0 Parent(s):

Єдиний коміт - очищення історії

Browse files
Files changed (43) hide show
  1. .gitignore +14 -0
  2. .gradio/certificate.pem +31 -0
  3. HELP.md +262 -0
  4. README.md +27 -0
  5. app.py +100 -0
  6. config.json +8 -0
  7. config.py +88 -0
  8. gradio_config.json +8 -0
  9. interface.py +69 -0
  10. jira_assistant.py +545 -0
  11. modules/ai_analysis/ai_assistant.py +186 -0
  12. modules/ai_analysis/ai_assistant_integration_mod.py +838 -0
  13. modules/ai_analysis/ai_assistant_methods.py +137 -0
  14. modules/ai_analysis/faiss_utils.py +405 -0
  15. modules/ai_analysis/google_embeddings_utils.py +175 -0
  16. modules/ai_analysis/indices_initializer.py +83 -0
  17. modules/ai_analysis/jira_ai_report.py +398 -0
  18. modules/ai_analysis/jira_hybrid_chat.py +669 -0
  19. modules/ai_analysis/jira_qa_assistant.py +418 -0
  20. modules/config/ai_settings.py +47 -0
  21. modules/config/logging_config.py +52 -0
  22. modules/config/paths.py +9 -0
  23. modules/core/app_manager.py +648 -0
  24. modules/data_analysis/statistics.py +278 -0
  25. modules/data_analysis/visualizations.py +640 -0
  26. modules/data_import/csv_importer.py +347 -0
  27. modules/data_import/jira_api.py +384 -0
  28. modules/data_management/data_manager.py +500 -0
  29. modules/data_management/data_processor.py +23 -0
  30. modules/data_management/hash_utils.py +51 -0
  31. modules/data_management/index_manager.py +606 -0
  32. modules/data_management/index_utils.py +457 -0
  33. modules/data_management/session_manager.py +463 -0
  34. modules/data_management/unified_index_manager.py +571 -0
  35. modules/interface/ai_assistant_ui.py +139 -0
  36. modules/interface/csv_analysis_ui.py +551 -0
  37. modules/interface/integrations_ui.py +34 -0
  38. modules/interface/jira_api_ui.py +46 -0
  39. modules/interface/local_data_helper.py +207 -0
  40. modules/interface/visualizations_ui.py +112 -0
  41. modules/reporting/report_generator.py +374 -0
  42. prompts.py +111 -0
  43. 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