logdetective 1.9.0__tar.gz → 2.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {logdetective-1.9.0 → logdetective-2.0.1}/PKG-INFO +28 -3
  2. {logdetective-1.9.0 → logdetective-2.0.1}/README.md +27 -2
  3. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/extractors.py +4 -2
  4. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/prompts.yml +2 -2
  5. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/gitlab.py +1 -1
  6. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/koji.py +2 -10
  7. logdetective-2.0.1/logdetective/server/llm.py +300 -0
  8. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/models.py +24 -1
  9. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/server.py +8 -53
  10. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/templates/gitlab_full_comment.md.j2 +4 -2
  11. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/templates/gitlab_short_comment.md.j2 +3 -1
  12. logdetective-2.0.1/logdetective/server/utils.py +122 -0
  13. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/utils.py +2 -2
  14. {logdetective-1.9.0 → logdetective-2.0.1}/pyproject.toml +1 -1
  15. logdetective-1.9.0/logdetective/server/llm.py +0 -191
  16. {logdetective-1.9.0 → logdetective-2.0.1}/LICENSE +0 -0
  17. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/__init__.py +0 -0
  18. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/constants.py +0 -0
  19. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/drain3.ini +0 -0
  20. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/logdetective.py +0 -0
  21. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/models.py +0 -0
  22. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/prompts-summary-first.yml +0 -0
  23. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/prompts-summary-only.yml +0 -0
  24. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/remote_log.py +0 -0
  25. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/__init__.py +0 -0
  26. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/compressors.py +0 -0
  27. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/config.py +0 -0
  28. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/database/__init__.py +0 -0
  29. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/database/base.py +0 -0
  30. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/database/models/__init__.py +0 -0
  31. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/database/models/exceptions.py +0 -0
  32. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/database/models/koji.py +0 -0
  33. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/database/models/merge_request_jobs.py +0 -0
  34. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/database/models/metrics.py +0 -0
  35. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/emoji.py +0 -0
  36. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/exceptions.py +0 -0
  37. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/metric.py +0 -0
  38. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/plot.py +0 -0
  39. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/skip_snippets.yml +0 -0
  40. {logdetective-1.9.0 → logdetective-2.0.1}/logdetective.1.asciidoc +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: logdetective
3
- Version: 1.9.0
3
+ Version: 2.0.1
4
4
  Summary: Log using LLM AI to search for build/test failures and provide ideas for fixing these.
5
5
  License: Apache-2.0
6
6
  Author: Jiri Podivin
@@ -124,6 +124,7 @@ Note that streaming with some models (notably Meta-Llama-3 is broken) is broken
124
124
 
125
125
  Real Example
126
126
  ------------
127
+
127
128
  Let's have a look at a real world example. Log Detective can work with any logs though we optimize it for RPM build logs.
128
129
 
129
130
  We're going to analyze a failed build of a python-based library that happened in Fedora Koji buildsystem:
@@ -187,7 +188,7 @@ It looks like a wall of text. Similar to any log. The main difference is that he
187
188
 
188
189
 
189
190
  Contributing
190
- ------------
191
+ ============
191
192
 
192
193
  Contributions are welcome! Please submit a pull request if you have any improvements or new features to add. Make sure your changes pass all existing tests before submitting.
193
194
  For bigger code changes, please consult us first by creating an issue.
@@ -304,7 +305,7 @@ podman-compose up server
304
305
  - Run Visual Stdio Code debug configuration named *Python Debug: Remote Attach*
305
306
 
306
307
  Server
307
- ------
308
+ ======
308
309
 
309
310
  FastApi based server is implemented in `logdetective/server.py`. In order to run it in a development mode,
310
311
  simply start llama-cpp-python server with your chosen model as described in llama-cpp-python [docs](https://llama-cpp-python.readthedocs.io/en/latest/server/#running-the-server).
@@ -335,6 +336,30 @@ Model can be downloaded from [our Hugging Space](https://huggingface.co/fedora-c
335
336
  $ curl -L -o models/mistral-7b-instruct-v0.3.Q4_K.gguf https://huggingface.co/fedora-copr/Mistral-7B-Instruct-v0.3-GGUF/resolve/main/ggml-model-Q4_K.gguf
336
337
  ```
337
338
 
339
+ Filtering snippet analysis by relevance
340
+ ---------------------------------------
341
+
342
+ When using `/analyze/staged` API, it is possible to enable filtering analyzed snippets by their estimated relavance, submitting only those with highest meansure of relevance for final analysis.
343
+
344
+ **Note**: This feautre requires LLM provider with support for JSON structured output. Smaller models, even though techically capable of providing structured output, may not be able to appropriatelly estimate snippet relevance.
345
+
346
+ Filtering is disabled by default and must be enabled by setting value of `top_k_snippets` field in `general` section of server configuration. Value indicates number of snippets with highest estimated relavance that will be submitted for final analysis.
347
+
348
+ Example:
349
+
350
+ ```
351
+ general:
352
+ devmode: False
353
+ packages:
354
+ - .*
355
+ excluded_packages:
356
+ - ^redhat-internal-.*
357
+ top_k_snippets: 10
358
+ ```
359
+
360
+ If all snippets are rated the same, the filtering is skipped and warning raised in logs.
361
+ Values higher than total number of snippets, as set by `max_clusters` in the `extrator` section of config, also result in filtering being skipped.
362
+
338
363
  Generate a new database revision with alembic
339
364
  ---------------------------------------------
340
365
 
@@ -79,6 +79,7 @@ Note that streaming with some models (notably Meta-Llama-3 is broken) is broken
79
79
 
80
80
  Real Example
81
81
  ------------
82
+
82
83
  Let's have a look at a real world example. Log Detective can work with any logs though we optimize it for RPM build logs.
83
84
 
84
85
  We're going to analyze a failed build of a python-based library that happened in Fedora Koji buildsystem:
@@ -142,7 +143,7 @@ It looks like a wall of text. Similar to any log. The main difference is that he
142
143
 
143
144
 
144
145
  Contributing
145
- ------------
146
+ ============
146
147
 
147
148
  Contributions are welcome! Please submit a pull request if you have any improvements or new features to add. Make sure your changes pass all existing tests before submitting.
148
149
  For bigger code changes, please consult us first by creating an issue.
@@ -259,7 +260,7 @@ podman-compose up server
259
260
  - Run Visual Stdio Code debug configuration named *Python Debug: Remote Attach*
260
261
 
261
262
  Server
262
- ------
263
+ ======
263
264
 
264
265
  FastApi based server is implemented in `logdetective/server.py`. In order to run it in a development mode,
265
266
  simply start llama-cpp-python server with your chosen model as described in llama-cpp-python [docs](https://llama-cpp-python.readthedocs.io/en/latest/server/#running-the-server).
@@ -290,6 +291,30 @@ Model can be downloaded from [our Hugging Space](https://huggingface.co/fedora-c
290
291
  $ curl -L -o models/mistral-7b-instruct-v0.3.Q4_K.gguf https://huggingface.co/fedora-copr/Mistral-7B-Instruct-v0.3-GGUF/resolve/main/ggml-model-Q4_K.gguf
291
292
  ```
292
293
 
294
+ Filtering snippet analysis by relevance
295
+ ---------------------------------------
296
+
297
+ When using `/analyze/staged` API, it is possible to enable filtering analyzed snippets by their estimated relavance, submitting only those with highest meansure of relevance for final analysis.
298
+
299
+ **Note**: This feautre requires LLM provider with support for JSON structured output. Smaller models, even though techically capable of providing structured output, may not be able to appropriatelly estimate snippet relevance.
300
+
301
+ Filtering is disabled by default and must be enabled by setting value of `top_k_snippets` field in `general` section of server configuration. Value indicates number of snippets with highest estimated relavance that will be submitted for final analysis.
302
+
303
+ Example:
304
+
305
+ ```
306
+ general:
307
+ devmode: False
308
+ packages:
309
+ - .*
310
+ excluded_packages:
311
+ - ^redhat-internal-.*
312
+ top_k_snippets: 10
313
+ ```
314
+
315
+ If all snippets are rated the same, the filtering is skipped and warning raised in logs.
316
+ Values higher than total number of snippets, as set by `max_clusters` in the `extrator` section of config, also result in filtering being skipped.
317
+
293
318
  Generate a new database revision with alembic
294
319
  ---------------------------------------------
295
320
 
@@ -20,7 +20,8 @@ class DrainExtractor:
20
20
  context: bool = False,
21
21
  max_clusters=8,
22
22
  skip_snippets: SkipSnippets = SkipSnippets({}),
23
- ):
23
+ max_snippet_len: int = 2000
24
+ ): # pylint: disable=R0913,R0917
24
25
  config = TemplateMinerConfig()
25
26
  config.load(f"{os.path.dirname(__file__)}/drain3.ini")
26
27
  config.profiling_enabled = verbose
@@ -29,11 +30,12 @@ class DrainExtractor:
29
30
  self.verbose = verbose
30
31
  self.context = context
31
32
  self.skip_snippets = skip_snippets
33
+ self.max_snippet_len = max_snippet_len
32
34
 
33
35
  def __call__(self, log: str) -> list[Tuple[int, str]]:
34
36
  out = []
35
37
  # Create chunks
36
- chunks = list(get_chunks(log))
38
+ chunks = list(get_chunks(log, self.max_snippet_len))
37
39
  # Keep only chunks that don't match any of the excluded patterns
38
40
  chunks = [
39
41
  (_, chunk)
@@ -22,8 +22,8 @@ prompt_template: |
22
22
  Analysis:
23
23
 
24
24
  snippet_prompt_template: |
25
- Analyse following RPM build log snippet. Describe contents accurately, without speculation or suggestions for resolution.
26
-
25
+ Analyse following RPM build log snippet. Describe contents accurately, without speculation or suggestions for resolution
26
+ and provide estimate of snippet relevance.
27
27
  Your analysis must be as concise as possible, while keeping relevant information intact.
28
28
 
29
29
  Snippet:
@@ -434,7 +434,7 @@ async def generate_mr_comment(
434
434
  content = tpl.render(
435
435
  package=job.project_name,
436
436
  explanation=response.explanation.text,
437
- certainty=f"{response.response_certainty:.2f}",
437
+ certainty=response.response_certainty,
438
438
  emoji_face=emoji_face,
439
439
  snippets=response.snippets,
440
440
  log_url=log_url,
@@ -4,7 +4,6 @@ from typing import Any, Callable, Optional
4
4
 
5
5
  import backoff
6
6
  import koji
7
- from logdetective.server.config import LOG
8
7
  from logdetective.server.exceptions import (
9
8
  KojiInvalidTaskID,
10
9
  LogDetectiveConnectionError,
@@ -12,23 +11,16 @@ from logdetective.server.exceptions import (
12
11
  LogsTooLargeError,
13
12
  UnknownTaskType,
14
13
  )
15
-
14
+ from logdetective.server.utils import connection_error_giveup
16
15
 
17
16
  FAILURE_LOG_REGEX = re.compile(r"(\w*\.log)")
18
17
 
19
18
 
20
- def connection_error_giveup(details: backoff._typing.Details) -> None:
21
- """
22
- Too many connection errors, give up.
23
- """
24
- LOG.error("Too many connection errors, giving up. %s", details["exception"])
25
- raise LogDetectiveConnectionError() from details["exception"]
26
-
27
-
28
19
  @backoff.on_exception(
29
20
  backoff.expo,
30
21
  koji.GenericError,
31
22
  max_time=60,
23
+ on_giveup=connection_error_giveup,
32
24
  )
33
25
  async def call_koji(func: Callable, *args, **kwargs) -> Any:
34
26
  """
@@ -0,0 +1,300 @@
1
+ import os
2
+ import asyncio
3
+ import random
4
+ from typing import List, Tuple, Dict
5
+
6
+ import backoff
7
+ from fastapi import HTTPException
8
+ from pydantic import ValidationError
9
+
10
+ import aiohttp
11
+ from openai import AsyncStream
12
+ from openai.types.chat import ChatCompletionChunk
13
+
14
+ from logdetective.utils import (
15
+ compute_certainty,
16
+ prompt_to_messages,
17
+ format_snippets,
18
+ )
19
+ from logdetective.server.config import (
20
+ LOG,
21
+ SERVER_CONFIG,
22
+ PROMPT_CONFIG,
23
+ CLIENT,
24
+ )
25
+ from logdetective.server.models import (
26
+ AnalyzedSnippet,
27
+ InferenceConfig,
28
+ Explanation,
29
+ StagedResponse,
30
+ SnippetAnalysis,
31
+ RatedSnippetAnalysis,
32
+ Response,
33
+ )
34
+ from logdetective.server.utils import (
35
+ format_analyzed_snippets,
36
+ mine_logs,
37
+ should_we_giveup,
38
+ we_give_up,
39
+ filter_snippets,
40
+ )
41
+
42
+
43
+ LLM_CPP_SERVER_TIMEOUT = os.environ.get("LLAMA_CPP_SERVER_TIMEOUT", 600)
44
+
45
+
46
+ @backoff.on_exception(
47
+ lambda: backoff.constant([10, 30, 120]),
48
+ aiohttp.ClientResponseError,
49
+ max_tries=4, # 4 tries and 3 retries
50
+ jitter=lambda wait_gen_value: random.uniform(wait_gen_value, wait_gen_value + 30),
51
+ giveup=should_we_giveup,
52
+ raise_on_giveup=False,
53
+ on_giveup=we_give_up,
54
+ )
55
+ async def call_llm(
56
+ messages: List[Dict[str, str]],
57
+ inference_cfg: InferenceConfig,
58
+ stream: bool = False,
59
+ structured_output: dict | None = None,
60
+ ) -> Explanation:
61
+ """Submit prompt to LLM.
62
+ inference_cfg: The configuration section from the config.json representing
63
+ the relevant inference server for this request.
64
+ """
65
+ LOG.info("Analyzing the text")
66
+
67
+ LOG.info("Submitting to /v1/chat/completions endpoint")
68
+
69
+ kwargs = {}
70
+
71
+ # OpenAI API does not guarantee that the behavior for parameter set to `None`
72
+ # and parameter not given at all is the same.
73
+ # We build a dictionary of parameters based on the configuration.
74
+ if inference_cfg.log_probs:
75
+ LOG.info("Requesting log probabilities from LLM")
76
+ kwargs["logprobs"] = inference_cfg.log_probs
77
+ if structured_output:
78
+ LOG.info("Requesting structured output from LLM")
79
+ response_format = {
80
+ "type": "json_schema",
81
+ "json_schema": {
82
+ "name": "rated-snippet-analysis",
83
+ "schema": structured_output,
84
+ },
85
+ }
86
+ kwargs["response_format"] = response_format
87
+
88
+ async with inference_cfg.get_limiter():
89
+ response = await CLIENT.chat.completions.create(
90
+ messages=messages,
91
+ max_tokens=inference_cfg.max_tokens,
92
+ stream=stream,
93
+ model=inference_cfg.model,
94
+ temperature=inference_cfg.temperature,
95
+ **kwargs,
96
+ )
97
+
98
+ if not response.choices[0].message.content:
99
+ LOG.error("No response content recieved from %s", inference_cfg.url)
100
+ raise RuntimeError()
101
+
102
+ message_content = response.choices[0].message.content
103
+
104
+ if response.choices[0].logprobs and response.choices[0].logprobs.content:
105
+ logprobs = [e.to_dict() for e in response.choices[0].logprobs.content]
106
+ else:
107
+ logprobs = None
108
+
109
+ return Explanation(
110
+ text=message_content,
111
+ logprobs=logprobs,
112
+ )
113
+
114
+
115
+ @backoff.on_exception(
116
+ lambda: backoff.constant([10, 30, 120]),
117
+ aiohttp.ClientResponseError,
118
+ max_tries=4, # 4 tries and 3 retries
119
+ jitter=lambda wait_gen_value: random.uniform(wait_gen_value, wait_gen_value + 30),
120
+ giveup=should_we_giveup,
121
+ raise_on_giveup=False,
122
+ on_giveup=we_give_up,
123
+ )
124
+ async def call_llm_stream(
125
+ messages: List[Dict[str, str]],
126
+ inference_cfg: InferenceConfig,
127
+ stream: bool = False,
128
+ ) -> AsyncStream[ChatCompletionChunk]:
129
+ """Submit prompt to LLM and recieve stream of tokens as a result.
130
+ inference_cfg: The configuration section from the config.json representing
131
+ the relevant inference server for this request.
132
+ """
133
+ LOG.info("Analyzing the text")
134
+
135
+ LOG.info("Submitting to /v1/chat/completions endpoint")
136
+
137
+ async with inference_cfg.get_limiter():
138
+ response = await CLIENT.chat.completions.create(
139
+ messages=messages,
140
+ max_tokens=inference_cfg.max_tokens,
141
+ logprobs=inference_cfg.log_probs,
142
+ stream=stream,
143
+ model=inference_cfg.model,
144
+ temperature=inference_cfg.temperature,
145
+ )
146
+
147
+ return response
148
+
149
+
150
+ async def analyze_snippets(
151
+ log_summary: List[Tuple[int, str]], structured_output: dict | None = None
152
+ ) -> List[SnippetAnalysis | RatedSnippetAnalysis]:
153
+ """Submit log file snippets to the LLM and gather results"""
154
+ # Process snippets asynchronously
155
+ awaitables = [
156
+ call_llm(
157
+ prompt_to_messages(
158
+ PROMPT_CONFIG.snippet_prompt_template.format(s),
159
+ PROMPT_CONFIG.snippet_system_prompt,
160
+ SERVER_CONFIG.inference.system_role,
161
+ SERVER_CONFIG.inference.user_role,
162
+ ),
163
+ inference_cfg=SERVER_CONFIG.snippet_inference,
164
+ structured_output=structured_output,
165
+ )
166
+ for s in log_summary
167
+ ]
168
+ gathered_responses = await asyncio.gather(*awaitables)
169
+ analyzed_snippets = []
170
+
171
+ for response in gathered_responses:
172
+ if structured_output:
173
+ try:
174
+ snippet = RatedSnippetAnalysis.model_validate_json(response.text)
175
+ except ValidationError as ex:
176
+ LOG.error("Invalid data structure returned `%s`", response.text)
177
+ raise ex
178
+ else:
179
+ snippet = SnippetAnalysis(text=response.text)
180
+ analyzed_snippets.append(snippet)
181
+
182
+ return analyzed_snippets
183
+
184
+
185
+ async def perfrom_analysis(log_text: str) -> Response:
186
+ """Sumbit log file snippets in aggregate to LLM and retrieve results"""
187
+ log_summary = mine_logs(log_text)
188
+ log_summary = format_snippets(log_summary)
189
+ messages = prompt_to_messages(
190
+ PROMPT_CONFIG.prompt_template.format(log_summary),
191
+ PROMPT_CONFIG.default_system_prompt,
192
+ SERVER_CONFIG.inference.system_role,
193
+ SERVER_CONFIG.inference.user_role,
194
+ )
195
+ response = await call_llm(
196
+ messages,
197
+ inference_cfg=SERVER_CONFIG.inference,
198
+ )
199
+ certainty = 0
200
+
201
+ if response.logprobs is not None:
202
+ try:
203
+ certainty = compute_certainty(response.logprobs)
204
+ except ValueError as ex:
205
+ LOG.error("Error encountered while computing certainty: %s", ex)
206
+ raise HTTPException(
207
+ status_code=400,
208
+ detail=f"Couldn't compute certainty with data:\n{response.logprobs}",
209
+ ) from ex
210
+
211
+ return Response(explanation=response, response_certainty=certainty)
212
+
213
+
214
+ async def perform_analyis_stream(log_text: str) -> AsyncStream:
215
+ """Submit log file snippets in aggregate and return a stream of tokens"""
216
+ log_summary = mine_logs(log_text)
217
+ log_summary = format_snippets(log_summary)
218
+ messages = prompt_to_messages(
219
+ PROMPT_CONFIG.prompt_template.format(log_summary),
220
+ PROMPT_CONFIG.default_system_prompt,
221
+ SERVER_CONFIG.inference.system_role,
222
+ SERVER_CONFIG.inference.user_role,
223
+ )
224
+
225
+ stream = call_llm_stream(
226
+ messages,
227
+ inference_cfg=SERVER_CONFIG.inference,
228
+ )
229
+
230
+ # we need to figure out a better response here, this is how it looks rn:
231
+ # b'data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],
232
+ # "created":1744818071,"id":"chatcmpl-c9geTxNcQO7M9wR...
233
+ return stream
234
+
235
+
236
+ async def perform_staged_analysis(log_text: str) -> StagedResponse:
237
+ """Submit the log file snippets to the LLM and retrieve their results"""
238
+ log_summary = mine_logs(log_text)
239
+
240
+ if SERVER_CONFIG.general.top_k_snippets:
241
+ rated_snippets = await analyze_snippets(
242
+ log_summary=log_summary,
243
+ structured_output=RatedSnippetAnalysis.model_json_schema(),
244
+ )
245
+
246
+ # Extract original text and line number from `log_summary`
247
+ processed_snippets = [
248
+ AnalyzedSnippet(line_number=e[0][0], text=e[0][1], explanation=e[1])
249
+ for e in zip(log_summary, rated_snippets)
250
+ ]
251
+ processed_snippets = filter_snippets(
252
+ processed_snippets=processed_snippets,
253
+ top_k=SERVER_CONFIG.general.top_k_snippets,
254
+ )
255
+ LOG.info(
256
+ "Keeping %d of original %d snippets",
257
+ len(processed_snippets),
258
+ len(rated_snippets),
259
+ )
260
+ else:
261
+ processed_snippets = await analyze_snippets(log_summary=log_summary)
262
+
263
+ # Extract original text and line number from `log_summary`
264
+ processed_snippets = [
265
+ AnalyzedSnippet(line_number=e[0][0], text=e[0][1], explanation=e[1])
266
+ for e in zip(log_summary, processed_snippets)
267
+ ]
268
+
269
+ final_prompt = PROMPT_CONFIG.prompt_template_staged.format(
270
+ format_analyzed_snippets(processed_snippets)
271
+ )
272
+ messages = prompt_to_messages(
273
+ final_prompt,
274
+ PROMPT_CONFIG.staged_system_prompt,
275
+ SERVER_CONFIG.inference.system_role,
276
+ SERVER_CONFIG.inference.user_role,
277
+ )
278
+ final_analysis = await call_llm(
279
+ messages,
280
+ inference_cfg=SERVER_CONFIG.inference,
281
+ )
282
+
283
+ certainty = 0
284
+
285
+ if final_analysis.logprobs:
286
+ try:
287
+ certainty = compute_certainty(final_analysis.logprobs)
288
+ except ValueError as ex:
289
+ LOG.error("Error encountered while computing certainty: %s", ex)
290
+ raise HTTPException(
291
+ status_code=400,
292
+ detail=f"Couldn't compute certainty with data:\n"
293
+ f"{final_analysis.logprobs}",
294
+ ) from ex
295
+
296
+ return StagedResponse(
297
+ explanation=final_analysis,
298
+ snippets=processed_snippets,
299
+ response_certainty=certainty,
300
+ )
@@ -88,6 +88,24 @@ class EmojiHook(BaseModel):
88
88
  merge_request: EmojiMergeRequest = Field(default=None)
89
89
 
90
90
 
91
+ class SnippetAnalysis(BaseModel):
92
+ """Model of snippet analysis from LLM."""
93
+
94
+ text: str = Field(description="Analysis of log snippet contents.")
95
+
96
+
97
+ class RatedSnippetAnalysis(SnippetAnalysis):
98
+ """Model for rated snippet analysis. This model is used to generate
99
+ json schema for inference with structured output."""
100
+
101
+ relevance: int = Field(
102
+ ge=0,
103
+ le=100,
104
+ description="Estimate of likelyhood that snippet contains an error, "
105
+ "with 0 standing for completely unlikely, 100 for absolutely certain.",
106
+ )
107
+
108
+
91
109
  class Explanation(BaseModel):
92
110
  """Model of snippet or general log explanation from Log Detective"""
93
111
 
@@ -95,6 +113,7 @@ class Explanation(BaseModel):
95
113
  logprobs: Optional[List[Dict]] = None
96
114
 
97
115
  def __str__(self):
116
+ """Return text of the Explanation"""
98
117
  return self.text
99
118
 
100
119
 
@@ -106,7 +125,7 @@ class AnalyzedSnippet(BaseModel):
106
125
  line_number: location of snippet in original log
107
126
  """
108
127
 
109
- explanation: Explanation
128
+ explanation: SnippetAnalysis | RatedSnippetAnalysis
110
129
  text: str
111
130
  line_number: int
112
131
 
@@ -228,6 +247,7 @@ class ExtractorConfig(BaseModel):
228
247
  context: bool = True
229
248
  max_clusters: int = 8
230
249
  verbose: bool = False
250
+ max_snippet_len: int = 2000
231
251
 
232
252
  def __init__(self, data: Optional[dict] = None):
233
253
  super().__init__()
@@ -237,6 +257,7 @@ class ExtractorConfig(BaseModel):
237
257
  self.context = data.get("context", True)
238
258
  self.max_clusters = data.get("max_clusters", 8)
239
259
  self.verbose = data.get("verbose", False)
260
+ self.max_snippet_len = data.get("max_snippet_len", 2000)
240
261
 
241
262
 
242
263
  class GitLabInstanceConfig(BaseModel): # pylint: disable=too-many-instance-attributes
@@ -439,6 +460,7 @@ class GeneralConfig(BaseModel):
439
460
  devmode: bool = False
440
461
  sentry_dsn: HttpUrl | None = None
441
462
  collect_emojis_interval: int = 60 * 60 # seconds
463
+ top_k_snippets: int = 0
442
464
 
443
465
  def __init__(self, data: Optional[dict] = None):
444
466
  super().__init__()
@@ -452,6 +474,7 @@ class GeneralConfig(BaseModel):
452
474
  self.collect_emojis_interval = data.get(
453
475
  "collect_emojis_interval", 60 * 60
454
476
  ) # seconds
477
+ self.top_k_snippets = data.get("top_k_snippets", 0)
455
478
 
456
479
 
457
480
  class Config(BaseModel):
@@ -7,6 +7,7 @@ from typing import Annotated
7
7
  from io import BytesIO
8
8
 
9
9
  import matplotlib
10
+ import matplotlib.figure
10
11
  import matplotlib.pyplot
11
12
  from fastapi import (
12
13
  FastAPI,
@@ -34,21 +35,15 @@ from logdetective.server.database.models.exceptions import (
34
35
 
35
36
  import logdetective.server.database.base
36
37
 
37
- from logdetective.utils import (
38
- compute_certainty,
39
- format_snippets,
40
- prompt_to_messages,
41
- )
42
-
43
- from logdetective.server.config import SERVER_CONFIG, PROMPT_CONFIG, LOG
38
+ from logdetective.server.config import SERVER_CONFIG, LOG
44
39
  from logdetective.server.koji import (
45
40
  get_failed_log_from_task as get_failed_log_from_koji_task,
46
41
  )
47
42
  from logdetective.remote_log import RemoteLog
48
43
  from logdetective.server.llm import (
49
- mine_logs,
50
44
  perform_staged_analysis,
51
- submit_text,
45
+ perfrom_analysis,
46
+ perform_analyis_stream,
52
47
  )
53
48
  from logdetective.server.gitlab import process_gitlab_job_event
54
49
  from logdetective.server.metric import track_request, add_new_metrics, update_metrics
@@ -157,31 +152,8 @@ async def analyze_log(
157
152
  """
158
153
  remote_log = RemoteLog(build_log.url, http_session)
159
154
  log_text = await remote_log.process_url()
160
- log_summary = mine_logs(log_text)
161
- log_summary = format_snippets(log_summary)
162
- messages = prompt_to_messages(
163
- PROMPT_CONFIG.prompt_template.format(log_summary),
164
- PROMPT_CONFIG.default_system_prompt,
165
- SERVER_CONFIG.inference.system_role,
166
- SERVER_CONFIG.inference.user_role,
167
- )
168
- response = await submit_text(
169
- messages,
170
- inference_cfg=SERVER_CONFIG.inference,
171
- )
172
- certainty = 0
173
155
 
174
- if response.logprobs is not None:
175
- try:
176
- certainty = compute_certainty(response.logprobs)
177
- except ValueError as ex:
178
- LOG.error("Error encountered while computing certainty: %s", ex)
179
- raise HTTPException(
180
- status_code=400,
181
- detail=f"Couldn't compute certainty with data:\n{response.logprobs}",
182
- ) from ex
183
-
184
- return Response(explanation=response, response_certainty=certainty)
156
+ return await perfrom_analysis(log_text)
185
157
 
186
158
 
187
159
  @app.post("/analyze/staged", response_model=StagedResponse)
@@ -351,9 +323,7 @@ async def analyze_koji_task(task_id: int, koji_instance_config: KojiInstanceConf
351
323
  # Notify any callbacks that the analysis is complete.
352
324
  for callback in koji_instance_config.get_callbacks(task_id):
353
325
  LOG.info("Notifying callback %s of task %d completion", callback, task_id)
354
- asyncio.create_task(
355
- send_koji_callback(callback, task_id)
356
- )
326
+ asyncio.create_task(send_koji_callback(callback, task_id))
357
327
 
358
328
  # Now that it's sent, we can clear the callbacks for this task.
359
329
  koji_instance_config.clear_callbacks(task_id)
@@ -398,20 +368,8 @@ async def analyze_log_stream(
398
368
  """
399
369
  remote_log = RemoteLog(build_log.url, http_session)
400
370
  log_text = await remote_log.process_url()
401
- log_summary = mine_logs(log_text)
402
- log_summary = format_snippets(log_summary)
403
- messages = prompt_to_messages(
404
- PROMPT_CONFIG.prompt_template.format(log_summary),
405
- PROMPT_CONFIG.default_system_prompt,
406
- SERVER_CONFIG.inference.system_role,
407
- SERVER_CONFIG.inference.user_role,
408
- )
409
371
  try:
410
- stream = submit_text(
411
- messages,
412
- inference_cfg=SERVER_CONFIG.inference,
413
- stream=True,
414
- )
372
+ stream = perform_analyis_stream(log_text)
415
373
  except aiohttp.ClientResponseError as ex:
416
374
  raise HTTPException(
417
375
  status_code=400,
@@ -419,9 +377,6 @@ async def analyze_log_stream(
419
377
  f"[{ex.status}] {ex.message}",
420
378
  ) from ex
421
379
 
422
- # we need to figure out a better response here, this is how it looks rn:
423
- # b'data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],
424
- # "created":1744818071,"id":"chatcmpl-c9geTxNcQO7M9wR...
425
380
  return StreamingResponse(stream)
426
381
 
427
382
 
@@ -711,7 +666,7 @@ async def collect_emoji_task():
711
666
  instance.url,
712
667
  datetime.datetime.now(datetime.timezone.utc),
713
668
  )
714
- await collect_emojis(instance.get_connection(), TimePeriod(weeks="54"))
669
+ await collect_emojis(instance.get_connection(), TimePeriod(weeks=54))
715
670
  LOG.info(
716
671
  "Collect emoji feedback finished at %s",
717
672
  datetime.datetime.now(datetime.timezone.utc),
@@ -1,7 +1,9 @@
1
1
  The package {{ package }} failed to build, here is a possible explanation why.
2
2
 
3
3
  Please know that the explanation was provided by AI and may be incorrect.
4
- In this case, we are {{ certainty }}% certain of the response {{ emoji_face }}.
4
+ {% if certainty > 0 %}
5
+ In this case, we are {{ "%.2f" | format(certainty) }}% certain of the response {{ emoji_face }}.
6
+ {% endif %}
5
7
 
6
8
  {{ explanation }}
7
9
 
@@ -10,7 +12,7 @@ In this case, we are {{ certainty }}% certain of the response {{ emoji_face }}.
10
12
  {% for snippet in snippets %}
11
13
  <li>
12
14
  <b>Line {{ snippet.line_number }}:</b> <code>{{ snippet.text }}</code>
13
- {{ snippet.explanation }}
15
+ {{ snippet.explanation.text }}
14
16
  </li>
15
17
  {% endfor %}
16
18
  </ul>
@@ -1,7 +1,9 @@
1
1
  The package {{ package }} failed to build, here is a possible explanation why.
2
2
 
3
3
  Please know that the explanation was provided by AI and may be incorrect.
4
- In this case, we are {{ certainty }}% certain of the response {{ emoji_face }}.
4
+ {% if certainty > 0 %}
5
+ In this case, we are {{ "%.2f" | format(certainty) }}% certain of the response {{ emoji_face }}.
6
+ {% endif %}
5
7
 
6
8
  {{ explanation }}
7
9
 
@@ -0,0 +1,122 @@
1
+ from typing import List, Tuple
2
+
3
+ import aiohttp
4
+ from fastapi import HTTPException
5
+
6
+ from logdetective.constants import SNIPPET_DELIMITER
7
+ from logdetective.extractors import DrainExtractor
8
+ from logdetective.server.config import (
9
+ LOG,
10
+ SERVER_CONFIG,
11
+ SKIP_SNIPPETS_CONFIG,
12
+ )
13
+ from logdetective.server.exceptions import LogDetectiveConnectionError
14
+ from logdetective.server.models import AnalyzedSnippet, RatedSnippetAnalysis
15
+
16
+
17
+ def format_analyzed_snippets(snippets: list[AnalyzedSnippet]) -> str:
18
+ """Format snippets for submission into staged prompt."""
19
+ summary = f"\n{SNIPPET_DELIMITER}\n".join(
20
+ [f"[{e.text}] at line [{e.line_number}]: [{e.explanation}]" for e in snippets]
21
+ )
22
+ return summary
23
+
24
+
25
+ def mine_logs(log: str) -> List[Tuple[int, str]]:
26
+ """Extract snippets from log text"""
27
+ extractor = DrainExtractor(
28
+ verbose=True,
29
+ context=True,
30
+ max_clusters=SERVER_CONFIG.extractor.max_clusters,
31
+ skip_snippets=SKIP_SNIPPETS_CONFIG,
32
+ max_snippet_len=SERVER_CONFIG.extractor.max_snippet_len
33
+ )
34
+
35
+ LOG.info("Getting summary")
36
+ log_summary = extractor(log)
37
+
38
+ ratio = len(log_summary) / len(log.split("\n"))
39
+ LOG.debug("Log summary: \n %s", log_summary)
40
+ LOG.info("Compression ratio: %s", ratio)
41
+
42
+ return log_summary
43
+
44
+
45
+ def connection_error_giveup(details: dict) -> None:
46
+ """Too many connection errors, give up.
47
+ """
48
+ LOG.error("Too many connection errors, giving up. %s", details["exception"])
49
+ raise LogDetectiveConnectionError() from details["exception"]
50
+
51
+
52
+ def should_we_giveup(exc: aiohttp.ClientResponseError) -> bool:
53
+ """From backoff's docs:
54
+
55
+ > a function which accepts the exception and returns
56
+ > a truthy value if the exception should not be retried
57
+ """
58
+ LOG.info("Should we give up on retrying error %s", exc)
59
+ return exc.status < 400
60
+
61
+
62
+ def we_give_up(details: dict):
63
+ """Retries didn't work (or we got a different exc)
64
+ we give up and raise proper 500 for our API endpoint
65
+ """
66
+ LOG.error("Last exception: %s", details["exception"])
67
+ LOG.error("Inference error: %s", details["args"])
68
+ raise HTTPException(500, "Request to the inference API failed")
69
+
70
+
71
+ def select_relevance(snippet: AnalyzedSnippet) -> float:
72
+ """Retrieve relevance value from structure, if there is one."""
73
+ if not isinstance(snippet.explanation, RatedSnippetAnalysis):
74
+ LOG.exception("Only rated snippets can be ordered by relevance.")
75
+ raise ValueError
76
+ return snippet.explanation.relevance
77
+
78
+
79
+ def select_line_number(explanation: AnalyzedSnippet) -> int:
80
+ """Returns line number of original snippet."""
81
+ return explanation.line_number
82
+
83
+
84
+ def filter_snippets(
85
+ processed_snippets: List[AnalyzedSnippet], top_k: int
86
+ ) -> List[AnalyzedSnippet]:
87
+ """Filter snippets according to criteria in config while keeping them ordered by line number.
88
+ If all snippets recieved the same score, return them all.
89
+ AnalyzedSnippet objects must have `explanation` attribute set to `RatedSnippetAnalysis`,
90
+ otherwise raise `ValueError`."""
91
+
92
+ if top_k >= len(processed_snippets):
93
+ LOG.warning(
94
+ "The `top-k` parameter >= number of original snippets, skipping filtering."
95
+ )
96
+ return processed_snippets
97
+
98
+ # Sorting invokes `select_relevance` which also tests if objects actually
99
+ # have the score assigned. Otherwise it raises exception.
100
+ processed_snippets = sorted(processed_snippets, key=select_relevance, reverse=True)
101
+
102
+ # Check for failure mode when all snippets have
103
+ # the same relevance. In such cases there is no point in filtering
104
+ # and all snippets are returned.
105
+ max_relevance = processed_snippets[0].explanation.relevance
106
+ min_relevance = processed_snippets[-1].explanation.relevance
107
+
108
+ LOG.info(
109
+ "Analyzed snippets sorted. Max relevance: %d Min relevance: %e",
110
+ max_relevance,
111
+ min_relevance,
112
+ )
113
+ if max_relevance == min_relevance:
114
+ LOG.warning("All snippets recieved the same rating. Filtering disabled.")
115
+ return processed_snippets
116
+
117
+ processed_snippets = processed_snippets[:top_k]
118
+
119
+ # Re-sorting snippets by line number
120
+ processed_snippets = sorted(processed_snippets, key=select_line_number)
121
+
122
+ return processed_snippets
@@ -39,7 +39,7 @@ def chunk_continues(text: str, index: int) -> bool:
39
39
  return False
40
40
 
41
41
 
42
- def get_chunks(text: str) -> Generator[Tuple[int, str], None, None]:
42
+ def get_chunks(text: str, max_len: int = 2000) -> Generator[Tuple[int, str], None, None]:
43
43
  """Split log into chunks according to heuristic
44
44
  based on whitespace and backslash presence.
45
45
  """
@@ -54,7 +54,7 @@ def get_chunks(text: str) -> Generator[Tuple[int, str], None, None]:
54
54
  chunk += text[i]
55
55
  if text[i] == "\n":
56
56
  next_line_number += 1
57
- if i + 1 < text_len and chunk_continues(text, i):
57
+ if i + 1 < text_len and chunk_continues(text, i) and i + 1 < max_len:
58
58
  i += 1
59
59
  continue
60
60
  yield (original_line_number, chunk)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "logdetective"
3
- version = "1.9.0"
3
+ version = "2.0.1"
4
4
  description = "Log using LLM AI to search for build/test failures and provide ideas for fixing these."
5
5
  authors = ["Jiri Podivin <jpodivin@gmail.com>"]
6
6
  license = "Apache-2.0"
@@ -1,191 +0,0 @@
1
- import os
2
- import asyncio
3
- import random
4
- from typing import List, Tuple, Union, Dict
5
-
6
- import backoff
7
- from fastapi import HTTPException
8
-
9
- import aiohttp
10
- from openai import AsyncStream
11
- from openai.types.chat import ChatCompletionChunk
12
-
13
- from logdetective.constants import SNIPPET_DELIMITER
14
- from logdetective.extractors import DrainExtractor
15
- from logdetective.utils import (
16
- compute_certainty,
17
- prompt_to_messages,
18
- )
19
- from logdetective.server.config import (
20
- LOG,
21
- SERVER_CONFIG,
22
- PROMPT_CONFIG,
23
- CLIENT,
24
- SKIP_SNIPPETS_CONFIG,
25
- )
26
- from logdetective.server.models import (
27
- AnalyzedSnippet,
28
- InferenceConfig,
29
- Explanation,
30
- StagedResponse,
31
- )
32
-
33
-
34
- LLM_CPP_SERVER_TIMEOUT = os.environ.get("LLAMA_CPP_SERVER_TIMEOUT", 600)
35
-
36
-
37
- def format_analyzed_snippets(snippets: list[AnalyzedSnippet]) -> str:
38
- """Format snippets for submission into staged prompt."""
39
- summary = f"\n{SNIPPET_DELIMITER}\n".join(
40
- [
41
- f"[{e.text}] at line [{e.line_number}]: [{e.explanation.text}]"
42
- for e in snippets
43
- ]
44
- )
45
- return summary
46
-
47
-
48
- def mine_logs(log: str) -> List[Tuple[int, str]]:
49
- """Extract snippets from log text"""
50
- extractor = DrainExtractor(
51
- verbose=True,
52
- context=True,
53
- max_clusters=SERVER_CONFIG.extractor.max_clusters,
54
- skip_snippets=SKIP_SNIPPETS_CONFIG,
55
- )
56
-
57
- LOG.info("Getting summary")
58
- log_summary = extractor(log)
59
-
60
- ratio = len(log_summary) / len(log.split("\n"))
61
- LOG.debug("Log summary: \n %s", log_summary)
62
- LOG.info("Compression ratio: %s", ratio)
63
-
64
- return log_summary
65
-
66
-
67
- def should_we_giveup(exc: aiohttp.ClientResponseError) -> bool:
68
- """
69
- From backoff's docs:
70
-
71
- > a function which accepts the exception and returns
72
- > a truthy value if the exception should not be retried
73
- """
74
- LOG.info("Should we give up on retrying error %s", exc)
75
- return exc.status < 400
76
-
77
-
78
- def we_give_up(details: backoff._typing.Details):
79
- """
80
- retries didn't work (or we got a different exc)
81
- we give up and raise proper 500 for our API endpoint
82
- """
83
- LOG.error("Last exception: %s", details["exception"])
84
- LOG.error("Inference error: %s", details["args"])
85
- raise HTTPException(500, "Request to the inference API failed")
86
-
87
-
88
- @backoff.on_exception(
89
- lambda: backoff.constant([10, 30, 120]),
90
- aiohttp.ClientResponseError,
91
- max_tries=4, # 4 tries and 3 retries
92
- jitter=lambda wait_gen_value: random.uniform(wait_gen_value, wait_gen_value + 30),
93
- giveup=should_we_giveup,
94
- raise_on_giveup=False,
95
- on_giveup=we_give_up,
96
- )
97
- async def submit_text(
98
- messages: List[Dict[str, str]],
99
- inference_cfg: InferenceConfig,
100
- stream: bool = False,
101
- ) -> Union[Explanation, AsyncStream[ChatCompletionChunk]]:
102
- """Submit prompt to LLM.
103
- inference_cfg: The configuration section from the config.json representing
104
- the relevant inference server for this request.
105
- log_probs: number of token choices to produce log probs for
106
- """
107
- LOG.info("Analyzing the text")
108
-
109
- LOG.info("Submitting to /v1/chat/completions endpoint")
110
-
111
- async with inference_cfg.get_limiter():
112
- response = await CLIENT.chat.completions.create(
113
- messages=messages,
114
- max_tokens=inference_cfg.max_tokens,
115
- logprobs=inference_cfg.log_probs,
116
- stream=stream,
117
- model=inference_cfg.model,
118
- temperature=inference_cfg.temperature,
119
- )
120
-
121
- if isinstance(response, AsyncStream):
122
- return response
123
- if not response.choices[0].message.content:
124
- LOG.error("No response content recieved from %s", inference_cfg.url)
125
- raise RuntimeError()
126
- if response.choices[0].logprobs and response.choices[0].logprobs.content:
127
- logprobs = [e.to_dict() for e in response.choices[0].logprobs.content]
128
- else:
129
- logprobs = None
130
-
131
- return Explanation(
132
- text=response.choices[0].message.content,
133
- logprobs=logprobs,
134
- )
135
-
136
-
137
- async def perform_staged_analysis(log_text: str) -> StagedResponse:
138
- """Submit the log file snippets to the LLM and retrieve their results"""
139
- log_summary = mine_logs(log_text)
140
-
141
- # Process snippets asynchronously
142
- awaitables = [
143
- submit_text(
144
- prompt_to_messages(
145
- PROMPT_CONFIG.snippet_prompt_template.format(s),
146
- PROMPT_CONFIG.snippet_system_prompt,
147
- SERVER_CONFIG.inference.system_role,
148
- SERVER_CONFIG.inference.user_role,
149
- ),
150
- inference_cfg=SERVER_CONFIG.snippet_inference,
151
- )
152
- for s in log_summary
153
- ]
154
- analyzed_snippets = await asyncio.gather(*awaitables)
155
-
156
- analyzed_snippets = [
157
- AnalyzedSnippet(line_number=e[0][0], text=e[0][1], explanation=e[1])
158
- for e in zip(log_summary, analyzed_snippets)
159
- ]
160
- final_prompt = PROMPT_CONFIG.prompt_template_staged.format(
161
- format_analyzed_snippets(analyzed_snippets)
162
- )
163
- messages = prompt_to_messages(
164
- final_prompt,
165
- PROMPT_CONFIG.staged_system_prompt,
166
- SERVER_CONFIG.inference.system_role,
167
- SERVER_CONFIG.inference.user_role,
168
- )
169
- final_analysis = await submit_text(
170
- messages,
171
- inference_cfg=SERVER_CONFIG.inference,
172
- )
173
-
174
- certainty = 0
175
-
176
- if final_analysis.logprobs:
177
- try:
178
- certainty = compute_certainty(final_analysis.logprobs)
179
- except ValueError as ex:
180
- LOG.error("Error encountered while computing certainty: %s", ex)
181
- raise HTTPException(
182
- status_code=400,
183
- detail=f"Couldn't compute certainty with data:\n"
184
- f"{final_analysis.logprobs}",
185
- ) from ex
186
-
187
- return StagedResponse(
188
- explanation=final_analysis,
189
- snippets=analyzed_snippets,
190
- response_certainty=certainty,
191
- )
File without changes