PatrickRedStar commited on
Commit
d76ef9a
·
1 Parent(s): 754c34e
Files changed (7) hide show
  1. README.md +16 -10
  2. app.py +138 -43
  3. app.pyi +246 -0
  4. pipeline.py +75 -41
  5. preprocess.py +15 -0
  6. requirements.txt +1 -1
  7. retrieval.py +15 -0
README.md CHANGED
@@ -1,17 +1,17 @@
1
  ---
2
- title: Log Compiler App
3
  emoji: 🧠
4
  colorFrom: blue
5
  colorTo: green
6
  sdk: gradio
7
- sdk_version: "4.44.0"
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
- # Log Compiler App
13
 
14
- Gradio демо: вставляете логи/стектрейс, получаете тип инцидента, человеческое объяснение, вероятную причину, проверки и локальные ранбуки. Пайплайн использует трансформеры: zero-shot classifier, summarizer, sentence-embedding retriever (опционально reranker и NLI).
15
 
16
  ## Установка локально
17
 
@@ -25,7 +25,13 @@ pip install -r requirements.txt
25
  python app.py
26
  ```
27
 
28
- Если localhost недоступен (WSL/прокси), по умолчанию включён share-линк; управлять можно `GRADIO_SHARE=0/1`. `server_name=0.0.0.0` выставлен.
 
 
 
 
 
 
29
 
30
  ## Запуск на Hugging Face Spaces
31
 
@@ -35,15 +41,15 @@ python app.py
35
 
36
  ## Использование
37
 
38
- - Вставьте логи/стектрейс, выберите source (auto/python/java/node/k8s).
39
- - Опции: retrieval (локальный KB `kb/`), NLI-проверка гипотез, slider verbosity.
40
- - Нажмите **Analyze**, вкладки покажут Incident, Explanation, Cause+Checks, Retrieval, Verification, Ticket template. Есть Export JSON.
41
 
42
  ## Примеры
43
 
44
  - `samples/sample_python.txt` — HTTP timeout.
45
- - `samples/sample_k8s.txt` — CrashLoop/OOMKilled pod.
46
- - `samples/sample_java.txt` — NullPointerException auth failure.
47
 
48
  ## Состав
49
 
 
1
  ---
2
+ title: Анализатор логов
3
  emoji: 🧠
4
  colorFrom: blue
5
  colorTo: green
6
  sdk: gradio
7
+ sdk_version: "4.44.1"
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # Анализатор логов
13
 
14
+ Gradio‑демо: вставляете логи/стектрейс, получаете тип инцидента, человеческое объяснение, вероятную причину, набор проверок и локальные ранбуки. Пайплайн использует трансформеры (zero-shot классификатор, summarizer, sentence-embedding retriever, при желании reranker и NLI).
15
 
16
  ## Установка локально
17
 
 
25
  python app.py
26
  ```
27
 
28
+ По умолчанию приложение слушает `127.0.0.1:7860` без публичного шаринга. Чтобы открыть интерфейс наружу, задайте:
29
+
30
+ ```bash
31
+ GRADIO_HOST=0.0.0.0 GRADIO_SHARE=1 python app.py
32
+ ```
33
+
34
+ или выставьте нужный `GRADIO_SERVER_NAME` / `PORT`.
35
 
36
  ## Запуск на Hugging Face Spaces
37
 
 
41
 
42
  ## Использование
43
 
44
+ - Вставьте логи/стектрейс, выберите источник (auto/python/java/node/k8s).
45
+ - Опции: поиск по локальной базе (`kb/`), NLI-проверка гипотез, слайдер детализации объяснения.
46
+ - Нажмите **Анализировать**: вкладки покажут тип инцидента, пояснение, причину/проверки, найденные ранбуки, проверку гипотез и шаблон тикета. Можно выгрузить JSON.
47
 
48
  ## Примеры
49
 
50
  - `samples/sample_python.txt` — HTTP timeout.
51
+ - `samples/sample_k8s.txt` — под с CrashLoop/OOMKilled.
52
+ - `samples/sample_java.txt` — NullPointerException из-за auth failure.
53
 
54
  ## Состав
55
 
app.py CHANGED
@@ -9,25 +9,107 @@ from pipeline import IncidentPipeline, IncidentResult, serialize_result
9
  from preprocess import truncate_logs
10
 
11
 
 
 
 
 
 
 
 
 
 
 
12
  pipeline = IncidentPipeline()
13
 
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  def format_incident_section(result: IncidentResult) -> str:
16
- alt_text = ", ".join(f"{a['label']} ({a['score']:.2f})" for a in result.incident_alternatives)
17
- sigs = ", ".join(result.signatures) if result.signatures else "none"
 
 
 
 
 
18
  return (
19
- f"**Incident:** {result.incident_label} (confidence {result.incident_score:.2f})\n\n"
20
- f"**Top alternatives:** {alt_text if alt_text else 'n/a'}\n\n"
21
- f"**Detected signatures:** {sigs}"
22
  )
23
 
24
 
25
  def format_cause_section(result: IncidentResult) -> str:
 
 
 
26
  checks_md = "\n".join([f"- {c}" for c in result.checks])
27
- return f"**Likely cause:** {result.likely_cause}\n\n**Checks / next steps:**\n{checks_md}"
28
 
29
 
30
  def analyze_logs(logs: str, source: str, use_retrieval: bool, use_nli: bool, verbosity: int):
 
 
 
31
  try:
32
  res = pipeline.process(
33
  logs,
@@ -37,7 +119,7 @@ def analyze_logs(logs: str, source: str, use_retrieval: bool, use_nli: bool, ver
37
  verbosity=verbosity,
38
  )
39
  except Exception as exc:
40
- message = f"Error: {exc}"
41
  empty_table: List[List[Any]] = []
42
  return (
43
  message,
@@ -46,7 +128,7 @@ def analyze_logs(logs: str, source: str, use_retrieval: bool, use_nli: bool, ver
46
  empty_table,
47
  empty_table,
48
  None,
49
- f"Failed: {exc}",
50
  )
51
 
52
  retrieval_rows = [
@@ -64,33 +146,39 @@ def analyze_logs(logs: str, source: str, use_retrieval: bool, use_nli: bool, ver
64
  retrieval_rows,
65
  verification_rows,
66
  state_payload,
67
- "Analysis completed.",
68
  )
69
 
70
 
71
  def ticket_template(state: Optional[str], logs: str) -> str:
 
 
 
72
  if not state:
73
- return "Run analysis first."
74
  try:
75
  parsed = json.loads(state) if isinstance(state, str) else state
76
  except Exception:
77
- return "State corrupted. Re-run analysis."
78
  clipped_logs = truncate_logs(logs, head_lines=30, tail_lines=10, max_lines=60)
79
  checks = parsed.get("checks") or []
80
  checks_md = "\n".join(f"- {c}" for c in checks)
81
- summary = f"{parsed.get('incident_label','?')} — {parsed.get('explanation','')[:180]}"
82
  template = (
83
- f"Summary:\n{summary}\n\n"
84
- f"Steps to reproduce:\n- Describe sequence leading to error (fill in).\n- Attach failing request/sample data.\n\n"
85
- f"Expected:\n- Service handles request successfully.\n\n"
86
- f"Actual:\n- {parsed.get('likely_cause','')}\n\n"
87
- f"Checks performed / next steps:\n{checks_md}\n\n"
88
- f"Logs snippet:\n{clipped_logs}\n"
89
  )
90
  return template
91
 
92
 
93
  def export_json(state: Optional[str]):
 
 
 
94
  if not state:
95
  return None
96
  # If state is dict, dump; if already JSON string, use as-is.
@@ -102,47 +190,47 @@ def export_json(state: Optional[str]):
102
  return tmp.name
103
 
104
 
105
- with gr.Blocks(title="Log Compiler App") as demo:
106
- gr.Markdown("# Log Compiler App\nPaste logs/stacktrace to get incident classification, explanations, and runbook suggestions.")
107
- # Hidden textbox to store serialized state; avoids schema issues in HF Spaces.
108
  state_box = gr.Textbox(visible=False, show_label=False)
109
 
110
  with gr.Row():
111
- with gr.Column(scale=1):
112
- logs_input = gr.Textbox(lines=20, label="Logs / Stacktrace", placeholder="Paste logs here...")
113
  source_dropdown = gr.Dropdown(
114
  ["auto", "python", "java", "node", "k8s"],
115
  value="auto",
116
- label="Source",
117
  )
118
- use_retrieval = gr.Checkbox(value=True, label="Use retrieval (local KB)")
119
- use_nli = gr.Checkbox(value=False, label="Verify hypothesis (NLI)")
120
- verbosity_slider = gr.Slider(0, 2, value=1, step=1, label="Verbosity")
121
- analyze_btn = gr.Button("Analyze")
122
- ticket_btn = gr.Button("Generate ticket template")
123
- export_btn = gr.Button("Export JSON")
124
- json_output = gr.File(label="JSON export")
125
- status = gr.Markdown("Ready.")
126
- with gr.Column(scale=1.2):
127
- with gr.Tab("Incident Type"):
128
  incident_md = gr.Markdown()
129
- with gr.Tab("Human Explanation"):
130
  explanation_md = gr.Markdown()
131
- with gr.Tab("Likely Cause + Checks"):
132
  cause_md = gr.Markdown()
133
- with gr.Tab("Retrieved Runbooks"):
134
  retrieval_df = gr.Dataframe(
135
- headers=["Title", "Score", "Path", "Excerpt"],
136
  datatype=["str", "number", "str", "str"],
137
  interactive=False,
138
  )
139
- with gr.Tab("Verification"):
140
  verification_df = gr.Dataframe(
141
- headers=["Hypothesis", "Label", "Score"],
142
  datatype=["str", "str", "number"],
143
  interactive=False,
144
  )
145
- with gr.Tab("Ticket Template"):
146
  ticket_md = gr.Markdown()
147
 
148
  analyze_btn.click(
@@ -165,6 +253,13 @@ with gr.Blocks(title="Log Compiler App") as demo:
165
 
166
 
167
  if __name__ == "__main__":
168
- share_env = os.getenv("GRADIO_SHARE")
169
  in_hf_space = bool(os.getenv("SPACE_ID") or os.getenv("HF_SPACE"))
170
- demo.launch(server_name="0.0.0.0", share=False)
 
 
 
 
 
 
 
 
 
9
  from preprocess import truncate_logs
10
 
11
 
12
+ class DownloadOnlyFile(gr.File):
13
+ """Файл только для скачивания, скрытый из OpenAPI-схемы Gradio."""
14
+
15
+ is_template = True
16
+
17
+ @property
18
+ def skip_api(self) -> bool:
19
+ return True
20
+
21
+
22
  pipeline = IncidentPipeline()
23
 
24
 
25
+ LABEL_DISPLAY = {
26
+ "oom": "Переполнение памяти (OOM)",
27
+ "timeout": "Таймаут",
28
+ "auth_failure": "Ошибка аутентификации/авторизации",
29
+ "db_connection": "Сбой подключения к базе данных",
30
+ "dns_resolution": "Ошибка DNS",
31
+ "tls_handshake": "Ошибка TLS-рукопожатия",
32
+ "crashloop": "CrashLoop / повторные рестарты",
33
+ "null_pointer": "NullPointer / None reference",
34
+ "resource_exhaustion": "Исчерпание ресурсов",
35
+ "network_partition": "Сетевая изоляция",
36
+ }
37
+
38
+ SOURCE_DISPLAY = {
39
+ "python": "Python",
40
+ "java": "Java",
41
+ "node": "Node.js",
42
+ "k8s": "Kubernetes",
43
+ "auto": "Auto",
44
+ }
45
+
46
+ SIGNATURE_DISPLAY = {
47
+ "stacktrace": "стектрейс",
48
+ "timestamps": "таймстемпы",
49
+ "log_levels": "уровни логов",
50
+ "k8s": "ошибки Kubernetes",
51
+ "oom": "признаки OOM",
52
+ "timeout": "упоминания таймаута",
53
+ }
54
+
55
+ SPEC_SUFFIX = "_specific"
56
+
57
+
58
+ def human_label(label: str) -> str:
59
+ """
60
+ Возвращает человеко-понятное название категории инцидента.
61
+ """
62
+ if label.endswith(SPEC_SUFFIX):
63
+ base = label[: -len(SPEC_SUFFIX)]
64
+ source_name = SOURCE_DISPLAY.get(base, base)
65
+ return f"Категория, специфичная для {source_name}"
66
+ return LABEL_DISPLAY.get(label, label)
67
+
68
+
69
+ def human_signature(sig: str) -> str:
70
+ """
71
+ Конвертирует машинную сигнатуру в более дружелюбный текст.
72
+ """
73
+ return SIGNATURE_DISPLAY.get(sig, sig)
74
+
75
+
76
+ def env_flag(name: str, default: bool = False) -> bool:
77
+ """
78
+ Безопасно читает булевы переменные окружения (1/0, true/false и т.д.).
79
+ """
80
+ raw = os.getenv(name)
81
+ if raw is None:
82
+ return default
83
+ return raw.lower() in ("1", "true", "yes", "on")
84
+
85
+
86
  def format_incident_section(result: IncidentResult) -> str:
87
+ """
88
+ Формирует markdown-блок с типом инцидента, альтернативами и сигнатурами.
89
+ """
90
+ alt_text = ", ".join(
91
+ f"{human_label(a['label'])} ({a['score']:.2f})" for a in result.incident_alternatives
92
+ )
93
+ sigs = ", ".join(human_signature(sig) for sig in result.signatures) if result.signatures else "нет"
94
  return (
95
+ f"**Инцидент:** {human_label(result.incident_label)} (уверенность {result.incident_score:.2f})\n\n"
96
+ f"**Альтернативы:** {alt_text if alt_text else 'н/д'}\n\n"
97
+ f"**Обнаруженные сигнатуры:** {sigs}"
98
  )
99
 
100
 
101
  def format_cause_section(result: IncidentResult) -> str:
102
+ """
103
+ Создаёт markdown с причиной и списком проверок.
104
+ """
105
  checks_md = "\n".join([f"- {c}" for c in result.checks])
106
+ return f"**Вероятная причина:** {result.likely_cause}\n\n**Проверки / следующие шаги:**\n{checks_md}"
107
 
108
 
109
  def analyze_logs(logs: str, source: str, use_retrieval: bool, use_nli: bool, verbosity: int):
110
+ """
111
+ Основная функция кнопки «Анализировать»: прогоняет пайплайн и возвращает выводы.
112
+ """
113
  try:
114
  res = pipeline.process(
115
  logs,
 
119
  verbosity=verbosity,
120
  )
121
  except Exception as exc:
122
+ message = f"Ошибка: {exc}"
123
  empty_table: List[List[Any]] = []
124
  return (
125
  message,
 
128
  empty_table,
129
  empty_table,
130
  None,
131
+ f"Сбой: {exc}",
132
  )
133
 
134
  retrieval_rows = [
 
146
  retrieval_rows,
147
  verification_rows,
148
  state_payload,
149
+ "Анализ завершён.",
150
  )
151
 
152
 
153
  def ticket_template(state: Optional[str], logs: str) -> str:
154
+ """
155
+ Собирает черновик тикета опираясь на результаты последнего анализа.
156
+ """
157
  if not state:
158
+ return "Сначала запустите анализ."
159
  try:
160
  parsed = json.loads(state) if isinstance(state, str) else state
161
  except Exception:
162
+ return "Состояние повреждено. Повторите анализ."
163
  clipped_logs = truncate_logs(logs, head_lines=30, tail_lines=10, max_lines=60)
164
  checks = parsed.get("checks") or []
165
  checks_md = "\n".join(f"- {c}" for c in checks)
166
+ summary = f"{human_label(parsed.get('incident_label','?'))} — {parsed.get('explanation','')[:180]}"
167
  template = (
168
+ f"Сводка:\n{summary}\n\n"
169
+ f"Шаги для воспроизведения:\n- Опишите последовательность, которая привела к сбою.\n- Приложите проблемный запрос или данные.\n\n"
170
+ f"Ожидаемый результат:\n- Сервис успешно обрабатывает запрос.\n\n"
171
+ f"Фактический результат:\n- {parsed.get('likely_cause','')}\n\n"
172
+ f"Проверки / дальнейшие шаги:\n{checks_md}\n\n"
173
+ f"Фрагмент логов:\n{clipped_logs}\n"
174
  )
175
  return template
176
 
177
 
178
  def export_json(state: Optional[str]):
179
+ """
180
+ Сохраняет результат анализа во временный JSON и возвращает путь до него.
181
+ """
182
  if not state:
183
  return None
184
  # If state is dict, dump; if already JSON string, use as-is.
 
190
  return tmp.name
191
 
192
 
193
+ with gr.Blocks(title="Анализатор логов") as demo:
194
+ gr.Markdown("# Анализатор логов\nВставьте логи/стектрейс и получите тип инцидента, объяснения и подсказки по расследованию.")
195
+ # Скрытое поле для сериализованного состояния.
196
  state_box = gr.Textbox(visible=False, show_label=False)
197
 
198
  with gr.Row():
199
+ with gr.Column(scale=5):
200
+ logs_input = gr.Textbox(lines=20, label="Логи / стек", placeholder="Вставьте логи сюда...")
201
  source_dropdown = gr.Dropdown(
202
  ["auto", "python", "java", "node", "k8s"],
203
  value="auto",
204
+ label="Источник",
205
  )
206
+ use_retrieval = gr.Checkbox(value=True, label="Использовать поиск по базе знаний")
207
+ use_nli = gr.Checkbox(value=False, label="Проверять гипотезы (NLI)")
208
+ verbosity_slider = gr.Slider(0, 2, value=1, step=1, label="Детализация объяснения")
209
+ analyze_btn = gr.Button("Анализировать")
210
+ ticket_btn = gr.Button("Сформировать шаблон тикета")
211
+ export_btn = gr.Button("Экспорт JSON")
212
+ json_output = DownloadOnlyFile(label="Экспорт JSON")
213
+ status = gr.Markdown("Готово.")
214
+ with gr.Column(scale=6):
215
+ with gr.Tab("Тип инцидента"):
216
  incident_md = gr.Markdown()
217
+ with gr.Tab("Пояснение"):
218
  explanation_md = gr.Markdown()
219
+ with gr.Tab("Причина и проверки"):
220
  cause_md = gr.Markdown()
221
+ with gr.Tab("Найденные ранбуки"):
222
  retrieval_df = gr.Dataframe(
223
+ headers=["Название", "Сходство", "Путь", "Фрагмент"],
224
  datatype=["str", "number", "str", "str"],
225
  interactive=False,
226
  )
227
+ with gr.Tab("Проверка гипотез"):
228
  verification_df = gr.Dataframe(
229
+ headers=["Гипотеза", "Метка", "Счёт"],
230
  datatype=["str", "str", "number"],
231
  interactive=False,
232
  )
233
+ with gr.Tab("Шаблон тикета"):
234
  ticket_md = gr.Markdown()
235
 
236
  analyze_btn.click(
 
253
 
254
 
255
  if __name__ == "__main__":
 
256
  in_hf_space = bool(os.getenv("SPACE_ID") or os.getenv("HF_SPACE"))
257
+ share_flag = False if in_hf_space else env_flag("GRADIO_SHARE", default=False)
258
+ host = os.getenv("GRADIO_HOST") or os.getenv("GRADIO_SERVER_NAME") or "127.0.0.1"
259
+ port = int(os.getenv("PORT") or os.getenv("GRADIO_SERVER_PORT") or 7860)
260
+ demo.queue(api_open=False).launch(
261
+ server_name=host,
262
+ server_port=port,
263
+ share=share_flag,
264
+ show_api=False,
265
+ )
app.pyi ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import tempfile
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ import gradio as gr
7
+
8
+ from pipeline import IncidentPipeline, IncidentResult, serialize_result
9
+ from preprocess import truncate_logs
10
+
11
+ from gradio.events import Dependency
12
+
13
+ class DownloadOnlyFile(gr.File):
14
+ """Файл только для скачивания, скрытый из OpenAPI-схемы Gradio."""
15
+
16
+ is_template = True
17
+
18
+ @property
19
+ def skip_api(self) -> bool:
20
+ return True
21
+ from typing import Callable, Literal, Sequence, Any, TYPE_CHECKING
22
+ from gradio.blocks import Block
23
+ if TYPE_CHECKING:
24
+ from gradio.components import Timer
25
+
26
+
27
+ pipeline = IncidentPipeline()
28
+
29
+
30
+ LABEL_DISPLAY = {
31
+ "oom": "Переполнение памяти (OOM)",
32
+ "timeout": "Таймаут",
33
+ "auth_failure": "Ошибка аутентификации/авторизации",
34
+ "db_connection": "Сбой подключения к базе данных",
35
+ "dns_resolution": "Ошибка DNS",
36
+ "tls_handshake": "Ошибка TLS-рукопожатия",
37
+ "crashloop": "CrashLoop / повторные рестарты",
38
+ "null_pointer": "NullPointer / None reference",
39
+ "resource_exhaustion": "Исчерпание ресурсов",
40
+ "network_partition": "Сетевая изоляция",
41
+ }
42
+
43
+ SOURCE_DISPLAY = {
44
+ "python": "Python",
45
+ "java": "Java",
46
+ "node": "Node.js",
47
+ "k8s": "Kubernetes",
48
+ "auto": "Auto",
49
+ }
50
+
51
+ SIGNATURE_DISPLAY = {
52
+ "stacktrace": "стектрейс",
53
+ "timestamps": "таймстемпы",
54
+ "log_levels": "уровни логов",
55
+ "k8s": "ошибки Kubernetes",
56
+ "oom": "признаки OOM",
57
+ "timeout": "упоминания таймаута",
58
+ }
59
+
60
+ SPEC_SUFFIX = "_specific"
61
+
62
+
63
+ def human_label(label: str) -> str:
64
+ if label.endswith(SPEC_SUFFIX):
65
+ base = label[: -len(SPEC_SUFFIX)]
66
+ source_name = SOURCE_DISPLAY.get(base, base)
67
+ return f"Категория, специфичная для {source_name}"
68
+ return LABEL_DISPLAY.get(label, label)
69
+
70
+
71
+ def human_signature(sig: str) -> str:
72
+ return SIGNATURE_DISPLAY.get(sig, sig)
73
+
74
+
75
+ def env_flag(name: str, default: bool = False) -> bool:
76
+ raw = os.getenv(name)
77
+ if raw is None:
78
+ return default
79
+ return raw.lower() in ("1", "true", "yes", "on")
80
+
81
+
82
+ def format_incident_section(result: IncidentResult) -> str:
83
+ alt_text = ", ".join(
84
+ f"{human_label(a['label'])} ({a['score']:.2f})" for a in result.incident_alternatives
85
+ )
86
+ sigs = ", ".join(human_signature(sig) for sig in result.signatures) if result.signatures else "нет"
87
+ return (
88
+ f"**Инцидент:** {human_label(result.incident_label)} (уверенность {result.incident_score:.2f})\n\n"
89
+ f"**Альтернативы:** {alt_text if alt_text else 'н/д'}\n\n"
90
+ f"**Обнаруженные сигнатуры:** {sigs}"
91
+ )
92
+
93
+
94
+ def format_cause_section(result: IncidentResult) -> str:
95
+ checks_md = "\n".join([f"- {c}" for c in result.checks])
96
+ return f"**Вероятная причина:** {result.likely_cause}\n\n**Проверки / следующие шаги:**\n{checks_md}"
97
+
98
+
99
+ def analyze_logs(logs: str, source: str, use_retrieval: bool, use_nli: bool, verbosity: int):
100
+ try:
101
+ res = pipeline.process(
102
+ logs,
103
+ source=source,
104
+ use_retrieval=use_retrieval,
105
+ use_nli=use_nli,
106
+ verbosity=verbosity,
107
+ )
108
+ except Exception as exc:
109
+ message = f"Ошибка: {exc}"
110
+ empty_table: List[List[Any]] = []
111
+ return (
112
+ message,
113
+ "",
114
+ "",
115
+ empty_table,
116
+ empty_table,
117
+ None,
118
+ f"Сбой: {exc}",
119
+ )
120
+
121
+ retrieval_rows = [
122
+ [r["title"], round(r["score"], 3), r["path"], r["excerpt"]]
123
+ for r in res.retrieved
124
+ ]
125
+ verification_rows = [
126
+ [v["hypothesis"], v["label"], round(v["score"], 3)] for v in res.verification
127
+ ]
128
+ state_payload = serialize_result(res)
129
+ return (
130
+ format_incident_section(res),
131
+ res.explanation,
132
+ format_cause_section(res),
133
+ retrieval_rows,
134
+ verification_rows,
135
+ state_payload,
136
+ "Анализ завершён.",
137
+ )
138
+
139
+
140
+ def ticket_template(state: Optional[str], logs: str) -> str:
141
+ if not state:
142
+ return "Сначала запустите анализ."
143
+ try:
144
+ parsed = json.loads(state) if isinstance(state, str) else state
145
+ except Exception:
146
+ return "Состояние повреждено. Повторите анализ."
147
+ clipped_logs = truncate_logs(logs, head_lines=30, tail_lines=10, max_lines=60)
148
+ checks = parsed.get("checks") or []
149
+ checks_md = "\n".join(f"- {c}" for c in checks)
150
+ summary = f"{human_label(parsed.get('incident_label','?'))} — {parsed.get('explanation','')[:180]}"
151
+ template = (
152
+ f"Сводка:\n{summary}\n\n"
153
+ f"Шаги для воспроизведения:\n- Опишите последовательность, которая привела к сбою.\n- Приложите проблемный запрос или данные.\n\n"
154
+ f"Ожидаемый результат:\n- Сервис успешно обрабатывает запрос.\n\n"
155
+ f"Фактический результат:\n- {parsed.get('likely_cause','')}\n\n"
156
+ f"Проверки / дальнейшие шаги:\n{checks_md}\n\n"
157
+ f"Фрагмент логов:\n{clipped_logs}\n"
158
+ )
159
+ return template
160
+
161
+
162
+ def export_json(state: Optional[str]):
163
+ if not state:
164
+ return None
165
+ # If state is dict, dump; if already JSON string, use as-is.
166
+ data = json.dumps(state, ensure_ascii=False, indent=2) if isinstance(state, dict) else state
167
+ tmp = tempfile.NamedTemporaryFile("w", delete=False, suffix=".json", encoding="utf-8")
168
+ tmp.write(data)
169
+ tmp.flush()
170
+ tmp.close()
171
+ return tmp.name
172
+
173
+
174
+ with gr.Blocks(title="Анализатор логов") as demo:
175
+ gr.Markdown("# Анализатор логов\nВставьте логи/стектрейс и получите тип инцидента, объяснения и подсказки по расследованию.")
176
+ # Скрытое поле для сериализованного состояния.
177
+ state_box = gr.Textbox(visible=False, show_label=False)
178
+
179
+ with gr.Row():
180
+ with gr.Column(scale=5):
181
+ logs_input = gr.Textbox(lines=20, label="Логи / стек", placeholder="Вставьте логи сюда...")
182
+ source_dropdown = gr.Dropdown(
183
+ ["auto", "python", "java", "node", "k8s"],
184
+ value="auto",
185
+ label="Источник",
186
+ )
187
+ use_retrieval = gr.Checkbox(value=True, label="Использовать поиск по базе знаний")
188
+ use_nli = gr.Checkbox(value=False, label="Проверять гипотезы (NLI)")
189
+ verbosity_slider = gr.Slider(0, 2, value=1, step=1, label="Детализация объяснения")
190
+ analyze_btn = gr.Button("Анализировать")
191
+ ticket_btn = gr.Button("Сформировать шаблон тикета")
192
+ export_btn = gr.Button("Экспорт JSON")
193
+ json_output = DownloadOnlyFile(label="Экспорт JSON")
194
+ status = gr.Markdown("Готово.")
195
+ with gr.Column(scale=6):
196
+ with gr.Tab("Тип инцидента"):
197
+ incident_md = gr.Markdown()
198
+ with gr.Tab("Пояснение"):
199
+ explanation_md = gr.Markdown()
200
+ with gr.Tab("Причина и проверки"):
201
+ cause_md = gr.Markdown()
202
+ with gr.Tab("Найденные ранбуки"):
203
+ retrieval_df = gr.Dataframe(
204
+ headers=["Название", "Сходство", "Путь", "Фрагмент"],
205
+ datatype=["str", "number", "str", "str"],
206
+ interactive=False,
207
+ )
208
+ with gr.Tab("Проверка гипотез"):
209
+ verification_df = gr.Dataframe(
210
+ headers=["Гипотеза", "Метка", "Счёт"],
211
+ datatype=["str", "str", "number"],
212
+ interactive=False,
213
+ )
214
+ with gr.Tab("Шаблон тикета"):
215
+ ticket_md = gr.Markdown()
216
+
217
+ analyze_btn.click(
218
+ fn=analyze_logs,
219
+ inputs=[logs_input, source_dropdown, use_retrieval, use_nli, verbosity_slider],
220
+ outputs=[incident_md, explanation_md, cause_md, retrieval_df, verification_df, state_box, status],
221
+ )
222
+
223
+ ticket_btn.click(
224
+ fn=ticket_template,
225
+ inputs=[state_box, logs_input],
226
+ outputs=ticket_md,
227
+ )
228
+
229
+ export_btn.click(
230
+ fn=export_json,
231
+ inputs=state_box,
232
+ outputs=json_output,
233
+ )
234
+
235
+
236
+ if __name__ == "__main__":
237
+ in_hf_space = bool(os.getenv("SPACE_ID") or os.getenv("HF_SPACE"))
238
+ share_flag = False if in_hf_space else env_flag("GRADIO_SHARE", default=False)
239
+ host = os.getenv("GRADIO_HOST") or os.getenv("GRADIO_SERVER_NAME") or "127.0.0.1"
240
+ port = int(os.getenv("PORT") or os.getenv("GRADIO_SERVER_PORT") or 7860)
241
+ demo.queue(api_open=False).launch(
242
+ server_name=host,
243
+ server_port=port,
244
+ share=share_flag,
245
+ show_api=False,
246
+ )
pipeline.py CHANGED
@@ -28,6 +28,9 @@ CANDIDATE_LABELS = [
28
 
29
  @dataclass
30
  class IncidentResult:
 
 
 
31
  incident_label: str
32
  incident_score: float
33
  incident_alternatives: List[Dict]
@@ -40,7 +43,13 @@ class IncidentResult:
40
 
41
 
42
  class ModelStore:
 
 
 
43
  def __init__(self):
 
 
 
44
  self.classifier = pipeline(
45
  "zero-shot-classification",
46
  model="facebook/bart-large-mnli",
@@ -59,11 +68,20 @@ class ModelStore:
59
 
60
 
61
  class IncidentPipeline:
 
 
 
62
  def __init__(self):
 
 
 
63
  self.models = ModelStore()
64
  self.retriever = RunbookRetriever()
65
 
66
  def classify(self, text: str, source: str) -> Dict:
 
 
 
67
  labels = list(CANDIDATE_LABELS)
68
  if source and source != "auto":
69
  labels.append(f"{source}_specific")
@@ -77,6 +95,9 @@ class IncidentPipeline:
77
  return {"label": label, "score": score, "alternatives": alternatives}
78
 
79
  def explain(self, text: str, verbosity: int = 1) -> str:
 
 
 
80
  max_len = 180 + 60 * verbosity
81
  min_len = 40 + 20 * verbosity
82
  summary = self.models.summarizer(
@@ -90,78 +111,85 @@ class IncidentPipeline:
90
  def generate_cause_and_checks(
91
  self, result: PreprocessResult, label: str, retrieved: List[Dict]
92
  ) -> tuple[str, List[str]]:
 
 
 
93
  cause_map = {
94
- "oom": "Service likely exhausted memory and was terminated.",
95
- "crashloop": "Container keeps restarting due to repeated failures or failed health checks.",
96
- "timeout": "Upstream or dependency timed out handling the request.",
97
- "auth_failure": "Authentication/authorization failed (expired token, missing permissions, or misconfiguration).",
98
- "db_connection": "Database connection pool exhausted or connection refused.",
99
- "dns_resolution": "DNS resolution failed for upstream host.",
100
- "tls_handshake": "TLS handshake failed (bad cert, protocol mismatch).",
101
- "null_pointer": "Application hit null/None reference and crashed.",
102
- "resource_exhaustion": "System resources (CPU/file descriptors) exhausted.",
103
- "network_partition": "Network partition or connectivity issue between components.",
104
  }
105
- cause = cause_map.get(label, f"Most likely incident category: {label}.")
106
  checks: List[str] = [
107
- "Confirm timeframe of failure in logs and recent deploys.",
108
- "Check service and pod/resource metrics (CPU, memory, restarts) around the incident window.",
109
- "Inspect recent configuration or secrets changes.",
110
  ]
111
  if label == "oom" or "oom" in result.signatures:
112
  checks += [
113
- "Inspect container memory limits/requests and current usage.",
114
- "Review heap/thread dumps if available.",
115
- "Check for memory leaks or unbounded caches.",
116
- "Ensure JVM/Runtime memory flags are configured correctly.",
117
  ]
118
  if label in ("timeout",):
119
  checks += [
120
- "Measure latency between service and dependencies.",
121
- "Verify retry/backoff settings and circuit breakers.",
122
- "Check for slow queries or downstream saturation.",
123
  ]
124
  if label in ("auth_failure",):
125
  checks += [
126
- "Verify tokens/credentials validity and scopes.",
127
- "Check clock skew between services.",
128
- "Review authentication provider health and rate limits.",
129
  ]
130
  if label in ("db_connection",):
131
  checks += [
132
- "Check DB connection pool size vs load.",
133
- "Inspect database for locks or slow queries.",
134
- "Verify database host/port/DNS correctness.",
135
  ]
136
  if label in ("dns_resolution",):
137
  checks += [
138
- "Resolve target host from pod/host manually.",
139
- "Check DNS server health and recent DNS changes.",
140
- "Verify search domains and /etc/resolv.conf inside pod/container.",
141
  ]
142
  if label in ("tls_handshake",):
143
  checks += [
144
- "Validate certificates (expiry, SANs, chain).",
145
- "Check protocol/cipher compatibility between client and server.",
146
- "Inspect ALPN/SNI configuration.",
147
  ]
148
  if label in ("crashloop",):
149
  checks += [
150
- "Inspect startup probes/health checks and command overrides.",
151
- "Review last logs before restart for root cause.",
152
- "Confirm config/secret mounts exist and permissions are correct.",
153
  ]
154
  if retrieved:
155
- checks.append(f"Consult runbook: {retrieved[0]['title']} (score {retrieved[0]['score']:.2f}).")
156
  # Ensure at least 5 checks
157
  while len(checks) < 5:
158
- checks.append("Add extra diagnostic step: capture more logs and metrics.")
159
  return cause, checks[:10]
160
 
161
  def verify_hypotheses(self, premise: str, hypotheses: List[str]) -> List[Dict]:
 
 
 
162
  results = []
163
  for hyp in hypotheses:
164
- pred = self.models.nli({"text": premise, "text_pair": hyp})[0]
 
165
  results.append({"hypothesis": hyp, "label": pred["label"], "score": float(pred["score"])})
166
  return results
167
 
@@ -173,8 +201,11 @@ class IncidentPipeline:
173
  use_nli: bool = False,
174
  verbosity: int = 1,
175
  ) -> IncidentResult:
 
 
 
176
  if not raw_text or not raw_text.strip():
177
- raise ValueError("Logs input is empty. Please provide logs or stacktrace text.")
178
  pre = preprocess_logs(raw_text)
179
  cls = self.classify(pre.cleaned_text, source)
180
  explanation = self.explain(pre.cleaned_text, verbosity=verbosity)
@@ -182,7 +213,7 @@ class IncidentPipeline:
182
  cause, checks = self.generate_cause_and_checks(pre, cls["label"], retrieved)
183
  verification = []
184
  if use_nli:
185
- hypotheses = [cause] + [f"Runbook match: {r['title']}" for r in retrieved]
186
  verification = self.verify_hypotheses(pre.cleaned_text, hypotheses)
187
  return IncidentResult(
188
  incident_label=cls["label"],
@@ -198,4 +229,7 @@ class IncidentPipeline:
198
 
199
 
200
  def serialize_result(result: IncidentResult) -> str:
 
 
 
201
  return json.dumps(asdict(result), indent=2, ensure_ascii=False)
 
28
 
29
  @dataclass
30
  class IncidentResult:
31
+ """
32
+ Контейнер с результатом пайплайна по одному запуску.
33
+ """
34
  incident_label: str
35
  incident_score: float
36
  incident_alternatives: List[Dict]
 
43
 
44
 
45
  class ModelStore:
46
+ """
47
+ Хранит и переиспользует все необходимые ML-пайплайны.
48
+ """
49
  def __init__(self):
50
+ """
51
+ Загружает и кэширует все необходимые трансформерные пайплайны.
52
+ """
53
  self.classifier = pipeline(
54
  "zero-shot-classification",
55
  model="facebook/bart-large-mnli",
 
68
 
69
 
70
  class IncidentPipeline:
71
+ """
72
+ Компонуёт все стадии анализа логов в единый процесс.
73
+ """
74
  def __init__(self):
75
+ """
76
+ Собирает модели и ретривер, готовые к переиспользованию.
77
+ """
78
  self.models = ModelStore()
79
  self.retriever = RunbookRetriever()
80
 
81
  def classify(self, text: str, source: str) -> Dict:
82
+ """
83
+ Определяет тип инцидента zero-shot классификатором.
84
+ """
85
  labels = list(CANDIDATE_LABELS)
86
  if source and source != "auto":
87
  labels.append(f"{source}_specific")
 
95
  return {"label": label, "score": score, "alternatives": alternatives}
96
 
97
  def explain(self, text: str, verbosity: int = 1) -> str:
98
+ """
99
+ Делает сжатое пояснение к логам при помощи summarizer.
100
+ """
101
  max_len = 180 + 60 * verbosity
102
  min_len = 40 + 20 * verbosity
103
  summary = self.models.summarizer(
 
111
  def generate_cause_and_checks(
112
  self, result: PreprocessResult, label: str, retrieved: List[Dict]
113
  ) -> tuple[str, List[str]]:
114
+ """
115
+ Подбирает человеко-понятную причину и список проверок по категории.
116
+ """
117
  cause_map = {
118
+ "oom": "Сервис, вероятно, исчерпал память и был аварийно завершён.",
119
+ "crashloop": "Контейнер постоянно перезапускается из-за повторяющихся сбоев или неуспешных health-check.",
120
+ "timeout": "Верхний уровень или зависимость не ответили вовремя.",
121
+ "auth_failure": "Аутентификация/авторизация отклонена (истёкший токен, нехватка прав или неверная конфигурация).",
122
+ "db_connection": "Пул подключений к базе данных исчерпан либо соединение отвергнуто.",
123
+ "dns_resolution": "Не удалось разрешить DNS-имя целевого хоста.",
124
+ "tls_handshake": "TLS-рукопожатие завершилось ошибкой (сертификат, протокол или шифр).",
125
+ "null_pointer": "Приложение встретило null/None и аварийно завершилось.",
126
+ "resource_exhaustion": "Системные ресурсы (CPU/дескрипторы файлов) исчерпаны.",
127
+ "network_partition": "Сетевой разрыв или проблемы с связностью между компонентами.",
128
  }
129
+ cause = cause_map.get(label, f"Наиболее вероятная категория инцидента: {label}.")
130
  checks: List[str] = [
131
+ "Подтвердите временной интервал сбоя в логах и сопоставьте с последними релизами.",
132
+ "Проверьте метрики сервисов/подов (CPU, память, рестарты) вокруг окна инцидента.",
133
+ "Изучите недавние изменения конфигураций и секретов.",
134
  ]
135
  if label == "oom" or "oom" in result.signatures:
136
  checks += [
137
+ "Проверьте лимиты/requests памяти контейнера и фактическое потребление.",
138
+ "Если доступны, изучите дампы heap/thread.",
139
+ "Исключите утечки памяти и неограниченные кэши.",
140
+ "Убедитесь, что флаги памяти JVM/рантайма настроены корректно.",
141
  ]
142
  if label in ("timeout",):
143
  checks += [
144
+ "Замерьте задержки между сервисом и зависимостями.",
145
+ "Проверьте настройки ретраев/бэк-оффов и circuit breaker.",
146
+ "Поиск потенциально медленных запросов либо перегруженных зависимостей.",
147
  ]
148
  if label in ("auth_failure",):
149
  checks += [
150
+ "Проверьте валидность токенов/учётных данных и нужные scope.",
151
+ "Сверьте время между сервисами (clock skew).",
152
+ "Проверьте состояние провайдера аутентификации и его квоты.",
153
  ]
154
  if label in ("db_connection",):
155
  checks += [
156
+ "Сопоставьте размер пула БД с текущей нагрузкой.",
157
+ "Проверьте базу на блокировки или медленные запросы.",
158
+ "Убедитесь в корректности host/port/DNS для подключения.",
159
  ]
160
  if label in ("dns_resolution",):
161
  checks += [
162
+ "Попробуйте вручную резолвить хост из пода/хоста.",
163
+ "Проверьте здоровье DNS-серверов и свежие изменения записей.",
164
+ "Посмотрите search domains и /etc/resolv.conf внутри контейнера.",
165
  ]
166
  if label in ("tls_handshake",):
167
  checks += [
168
+ "Проверьте сертификаты (срок, SAN, цепочка).",
169
+ "Сравните поддерживаемые протоколы/шифры клиента и сервера.",
170
+ "Проверьте настройки ALPN/SNI.",
171
  ]
172
  if label in ("crashloop",):
173
  checks += [
174
+ "Проверьте startup/health‑пробы и переопределения команд.",
175
+ "Посмотрите последние логи перед рестартом для поиска первопричины.",
176
+ "Убедитесь, что конфиги/секреты смонтированы и права корректны.",
177
  ]
178
  if retrieved:
179
+ checks.append(f"Изучите ранбук: {retrieved[0]['title']} (сходство {retrieved[0]['score']:.2f}).")
180
  # Ensure at least 5 checks
181
  while len(checks) < 5:
182
+ checks.append("Добавьте шаг диагностики: снимите дополнительные логи и метрики.")
183
  return cause, checks[:10]
184
 
185
  def verify_hypotheses(self, premise: str, hypotheses: List[str]) -> List[Dict]:
186
+ """
187
+ Прогоняет набор гипотез через NLI, чтобы отметить подтверждение/опровержение.
188
+ """
189
  results = []
190
  for hyp in hypotheses:
191
+ raw = self.models.nli({"text": premise, "text_pair": hyp})
192
+ pred = raw[0] if isinstance(raw, list) else raw
193
  results.append({"hypothesis": hyp, "label": pred["label"], "score": float(pred["score"])})
194
  return results
195
 
 
201
  use_nli: bool = False,
202
  verbosity: int = 1,
203
  ) -> IncidentResult:
204
+ """
205
+ Полный пайплайн обработки логов: от предобработки до верификации.
206
+ """
207
  if not raw_text or not raw_text.strip():
208
+ raise ValueError("Поле логов пустое. Пожалуйста, вставьте текст логов или стектрейса.")
209
  pre = preprocess_logs(raw_text)
210
  cls = self.classify(pre.cleaned_text, source)
211
  explanation = self.explain(pre.cleaned_text, verbosity=verbosity)
 
213
  cause, checks = self.generate_cause_and_checks(pre, cls["label"], retrieved)
214
  verification = []
215
  if use_nli:
216
+ hypotheses = [cause] + [f"Совпадение с ранбуком: {r['title']}" for r in retrieved]
217
  verification = self.verify_hypotheses(pre.cleaned_text, hypotheses)
218
  return IncidentResult(
219
  incident_label=cls["label"],
 
229
 
230
 
231
  def serialize_result(result: IncidentResult) -> str:
232
+ """
233
+ Упаковывает результат в JSON-строку.
234
+ """
235
  return json.dumps(asdict(result), indent=2, ensure_ascii=False)
preprocess.py CHANGED
@@ -12,12 +12,18 @@ TIMESTAMP_RE = re.compile(r"\b\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?\b
12
 
13
  @dataclass
14
  class PreprocessResult:
 
 
 
15
  cleaned_text: str
16
  signatures: List[str]
17
  masked: List[str]
18
 
19
 
20
  def detect_signatures(text: str) -> List[str]:
 
 
 
21
  signatures = []
22
  if re.search(r"Traceback|Exception|Error:|Caused by:", text, re.IGNORECASE):
23
  signatures.append("stacktrace")
@@ -35,6 +41,9 @@ def detect_signatures(text: str) -> List[str]:
35
 
36
 
37
  def mask_sensitive(text: str) -> Tuple[str, List[str]]:
 
 
 
38
  masked = []
39
 
40
  def _mask(pattern: re.Pattern, placeholder: str, value: str) -> str:
@@ -51,6 +60,9 @@ def mask_sensitive(text: str) -> Tuple[str, List[str]]:
51
 
52
 
53
  def truncate_logs(text: str, head_lines: int = 120, tail_lines: int = 80, max_lines: int = 400) -> str:
 
 
 
54
  lines = text.splitlines()
55
  if len(lines) <= max_lines:
56
  return text
@@ -60,6 +72,9 @@ def truncate_logs(text: str, head_lines: int = 120, tail_lines: int = 80, max_li
60
 
61
 
62
  def preprocess_logs(raw_text: str) -> PreprocessResult:
 
 
 
63
  normalized = raw_text.strip()
64
  truncated = truncate_logs(normalized)
65
  masked_text, masked = mask_sensitive(truncated)
 
12
 
13
  @dataclass
14
  class PreprocessResult:
15
+ """
16
+ Результат предобработки: очищенный текст, сигнатуры и замаскированные значения.
17
+ """
18
  cleaned_text: str
19
  signatures: List[str]
20
  masked: List[str]
21
 
22
 
23
  def detect_signatures(text: str) -> List[str]:
24
+ """
25
+ Ищет в тексте характерные маркеры (стектрейсы, уровни логов и т.д.).
26
+ """
27
  signatures = []
28
  if re.search(r"Traceback|Exception|Error:|Caused by:", text, re.IGNORECASE):
29
  signatures.append("stacktrace")
 
41
 
42
 
43
  def mask_sensitive(text: str) -> Tuple[str, List[str]]:
44
+ """
45
+ Маскирует UUID/IP/почты/пути, возвращая новый текст и список найденных значений.
46
+ """
47
  masked = []
48
 
49
  def _mask(pattern: re.Pattern, placeholder: str, value: str) -> str:
 
60
 
61
 
62
  def truncate_logs(text: str, head_lines: int = 120, tail_lines: int = 80, max_lines: int = 400) -> str:
63
+ """
64
+ Обрезает длинные логи, сохраняя головы/хвост и вставляя разделитель.
65
+ """
66
  lines = text.splitlines()
67
  if len(lines) <= max_lines:
68
  return text
 
72
 
73
 
74
  def preprocess_logs(raw_text: str) -> PreprocessResult:
75
+ """
76
+ Комплексная подготовка логов к классификации: нормализация, маскировка, сигнатуры.
77
+ """
78
  normalized = raw_text.strip()
79
  truncated = truncate_logs(normalized)
80
  masked_text, masked = mask_sensitive(truncated)
requirements.txt CHANGED
@@ -1,4 +1,4 @@
1
- gradio==4.44.0
2
  transformers==4.38.2
3
  torch>=2.2.0,<3.0
4
  sentence-transformers==2.5.1
 
1
+ gradio==4.44.1
2
  transformers==4.38.2
3
  torch>=2.2.0,<3.0
4
  sentence-transformers==2.5.1
retrieval.py CHANGED
@@ -9,18 +9,27 @@ from sentence_transformers import CrossEncoder, SentenceTransformer, util
9
 
10
  @dataclass
11
  class RunbookDoc:
 
 
 
12
  path: str
13
  title: str
14
  content: str
15
 
16
 
17
  class RunbookRetriever:
 
 
 
18
  def __init__(
19
  self,
20
  kb_dir: str = "kb",
21
  embed_model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
22
  reranker_name: Optional[str] = "cross-encoder/ms-marco-MiniLM-L-6-v2",
23
  ):
 
 
 
24
  self.kb_dir = kb_dir
25
  # Force CPU to avoid CUDA capability mismatches in WSL/GPUs.
26
  self.device = torch.device("cpu")
@@ -42,6 +51,9 @@ class RunbookRetriever:
42
  self.doc_embeddings = None
43
 
44
  def _load_docs(self) -> List[RunbookDoc]:
 
 
 
45
  docs: List[RunbookDoc] = []
46
  if not os.path.isdir(self.kb_dir):
47
  return docs
@@ -56,6 +68,9 @@ class RunbookRetriever:
56
  return docs
57
 
58
  def search(self, query: str, top_k: int = 3):
 
 
 
59
  if not self.docs or self.doc_embeddings is None:
60
  return []
61
  query_emb = self.embed_model.encode(query, convert_to_tensor=True, device=self.device)
 
9
 
10
  @dataclass
11
  class RunbookDoc:
12
+ """
13
+ Представляет один Markdown-ранбук локальной БЗ.
14
+ """
15
  path: str
16
  title: str
17
  content: str
18
 
19
 
20
  class RunbookRetriever:
21
+ """
22
+ Отвечает за загрузку локальной базы знаний и поиск по ней.
23
+ """
24
  def __init__(
25
  self,
26
  kb_dir: str = "kb",
27
  embed_model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
28
  reranker_name: Optional[str] = "cross-encoder/ms-marco-MiniLM-L-6-v2",
29
  ):
30
+ """
31
+ Загружает все ранбуки и подготавливает модели (эмбеддер + опциональный reranker).
32
+ """
33
  self.kb_dir = kb_dir
34
  # Force CPU to avoid CUDA capability mismatches in WSL/GPUs.
35
  self.device = torch.device("cpu")
 
51
  self.doc_embeddings = None
52
 
53
  def _load_docs(self) -> List[RunbookDoc]:
54
+ """
55
+ Читает Markdown-файлы из kb_dir и превращает их в список RunbookDoc.
56
+ """
57
  docs: List[RunbookDoc] = []
58
  if not os.path.isdir(self.kb_dir):
59
  return docs
 
68
  return docs
69
 
70
  def search(self, query: str, top_k: int = 3):
71
+ """
72
+ Находит топ-k релевантных ранбуков по косинусному сходству (и reranker'у, если доступен).
73
+ """
74
  if not self.docs or self.doc_embeddings is None:
75
  return []
76
  query_emb = self.embed_model.encode(query, convert_to_tensor=True, device=self.device)